Skip to content

Commit 0fbedd4

Browse files
committed
feat(services): v5 color-mode, local-storage, in-memory-storage, script-injector
1 parent be23845 commit 0fbedd4

9 files changed

+310
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { ColorModeService } from './color-mode.service';
3+
4+
describe('ColorModeService', () => {
5+
6+
let service: ColorModeService;
7+
8+
beforeEach(() => {
9+
service = TestBed.inject(ColorModeService);
10+
});
11+
12+
it('should be created', () => {
13+
expect(service).toBeTruthy();
14+
});
15+
16+
it('should switch themes', () => {
17+
expect(service.colorMode()).toBeUndefined();
18+
service.colorMode.set('light');
19+
expect(service.colorMode()).toBe('light');
20+
service.colorMode.set('dark');
21+
expect(service.colorMode()).toBe('dark');
22+
service.colorMode.set('auto');
23+
expect(service.colorMode()).toBe('auto');
24+
});
25+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { DOCUMENT } from '@angular/common';
2+
import { DestroyRef, effect, inject, Injectable, signal, WritableSignal } from '@angular/core';
3+
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
4+
import { tap } from 'rxjs/operators';
5+
import { LocalStorageService } from './local-storage.service';
6+
7+
export type ColorMode = 'light' | 'dark' | 'auto' | string | undefined;
8+
9+
@Injectable({
10+
providedIn: 'root'
11+
})
12+
export class ColorModeService {
13+
14+
readonly #destroyRef: DestroyRef = inject(DestroyRef);
15+
readonly #document: Document = inject(DOCUMENT);
16+
readonly #localStorage: LocalStorageService = inject(LocalStorageService);
17+
18+
readonly eventName = signal('ColorSchemeChange');
19+
readonly localStorageItemName: WritableSignal<string | undefined> = signal(undefined);
20+
readonly localStorageItemName$ = toObservable(this.localStorageItemName);
21+
readonly colorMode: WritableSignal<ColorMode> = signal(undefined);
22+
23+
readonly colorModeEffect = effect(() => {
24+
const colorMode = this.colorMode();
25+
if (colorMode) {
26+
const localStorageItemName = this.localStorageItemName();
27+
localStorageItemName && this.setStoredTheme(localStorageItemName, colorMode);
28+
this.#setTheme(colorMode);
29+
}
30+
});
31+
32+
constructor() {
33+
this.localStorageItemName$
34+
.pipe(
35+
tap(params => {
36+
this.colorMode.set(this.getDefaultScheme(params));
37+
}),
38+
takeUntilDestroyed(this.#destroyRef)
39+
)
40+
.subscribe();
41+
}
42+
43+
getStoredTheme(localStorageItemName: string) {
44+
return this.#localStorage.getItem(localStorageItemName);
45+
}
46+
47+
setStoredTheme(localStorageItemName: string, colorMode: string) {
48+
return this.#localStorage.setItem(localStorageItemName, colorMode);
49+
}
50+
51+
removeStoredTheme(localStorageItemName: string) {
52+
this.#localStorage.removeItem(localStorageItemName);
53+
}
54+
55+
getDefaultScheme(localStorageItemName: string | undefined) {
56+
if (this.#document.defaultView === undefined) {
57+
return this.getDatasetTheme();
58+
}
59+
60+
const storedTheme = localStorageItemName && this.getStoredTheme(localStorageItemName);
61+
62+
return storedTheme ?? this.getDatasetTheme();
63+
};
64+
65+
getPrefersColorScheme() {
66+
return this.#document.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' :
67+
this.#document.defaultView?.matchMedia('(prefers-color-scheme: light)').matches ? 'light' :
68+
undefined;
69+
}
70+
71+
getDatasetTheme(): ColorMode {
72+
return <ColorMode>(this.#document.documentElement.dataset['coreuiTheme']);
73+
}
74+
75+
#setTheme(colorMode: ColorMode) {
76+
this.#document.documentElement.dataset['coreuiTheme'] = (colorMode === 'auto' ? this.getPrefersColorScheme() : colorMode);
77+
78+
const event = new Event(this.eventName());
79+
this.#document.documentElement.dispatchEvent(event);
80+
};
81+
82+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { InMemoryStorageService } from './in-memory-storage.service';
4+
5+
describe('InMemoryStorageService', () => {
6+
let service: InMemoryStorageService;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(InMemoryStorageService);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable({
4+
providedIn: 'root'
5+
})
6+
export class InMemoryStorageService implements Storage {
7+
8+
#storage = new Map<string, string>();
9+
10+
constructor() { }
11+
12+
public setItem(key: string, data: any): void {
13+
this.#storage.set(key, JSON.stringify(data));
14+
}
15+
16+
public getItem(key: string): any {
17+
return this.#storage.has(key) ? JSON.parse(this.#storage.get(key) ?? 'null') : undefined;
18+
}
19+
20+
public removeItem(key: string): void {
21+
this.#storage.delete(key);
22+
}
23+
24+
public clear() {
25+
this.#storage.clear();
26+
}
27+
28+
public get length() {
29+
return this.#storage.size;
30+
}
31+
32+
public key(index: number) {
33+
return Array.from(this.#storage.keys())[index];
34+
}
35+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { LocalStorageService } from './local-storage.service';
4+
5+
describe('LocalStorageService', () => {
6+
let service: LocalStorageService;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(LocalStorageService);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
2+
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
3+
import { BehaviorSubject } from 'rxjs';
4+
import { InMemoryStorageService } from './in-memory-storage.service';
5+
6+
@Injectable({
7+
providedIn: 'root'
8+
})
9+
export class LocalStorageService {
10+
11+
constructor(
12+
@Inject(PLATFORM_ID) private platformId: Object,
13+
@Inject(DOCUMENT) private document: Document
14+
) {
15+
this.#localStorage =
16+
isPlatformBrowser(this.platformId) && this.document.defaultView
17+
? this.document.defaultView?.localStorage
18+
: new InMemoryStorageService();
19+
}
20+
21+
#localStorage: Storage;
22+
readonly #data$ = new BehaviorSubject<{ key: string; data: any } | null>(null);
23+
public readonly data$ = this.#data$.asObservable();
24+
25+
public setItem(key: string, data: any): void {
26+
this.#localStorage.setItem(key, JSON.stringify(data));
27+
this.#data$.next({ key, data });
28+
}
29+
30+
public getItem(key: string): any {
31+
const data = JSON.parse(this.#localStorage.getItem(key) ?? 'null');
32+
this.#data$.next({ key, data });
33+
return data;
34+
}
35+
36+
public removeItem(key: string): void {
37+
this.#localStorage.removeItem(key);
38+
this.#data$.next({ key, data: null });
39+
}
40+
41+
public clear() {
42+
this.#localStorage.clear();
43+
this.#data$.next(null);
44+
}
45+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export { IntersectionService, IIntersectionObserverInit } from './intersection.service';
22
export { ListenersService, IListenersConfig } from './listeners.service';
33
export { ClassToggleService } from './class-toggle.service';
4+
export { LocalStorageService } from './local-storage.service';
5+
export { InMemoryStorageService } from './in-memory-storage.service';
6+
export { ColorModeService, ColorMode } from './color-mode.service';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { Renderer2 } from '@angular/core';
3+
4+
import { ScriptInjectorService } from './script-injector.service';
5+
6+
describe('ScriptInjectorService', () => {
7+
let service: ScriptInjectorService;
8+
9+
beforeEach(() => {
10+
TestBed.configureTestingModule({
11+
providers: [Renderer2]
12+
});
13+
service = TestBed.inject(ScriptInjectorService);
14+
});
15+
16+
it('should be created', () => {
17+
expect(service).toBeTruthy();
18+
});
19+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { DOCUMENT } from '@angular/common';
2+
import { inject, Injectable, Renderer2 } from '@angular/core';
3+
4+
export type ReferrerPolicy =
5+
''
6+
| 'no-referrer'
7+
| 'no-referrer-when-downgrade'
8+
| 'origin'
9+
| 'origin-when-cross-origin'
10+
| 'same-origin'
11+
| 'strict-origin'
12+
| 'strict-origin-when-cross-origin'
13+
| 'unsafe-url';
14+
15+
export interface IScriptAttributes {
16+
async?: boolean;
17+
blocking?: 'render';
18+
crossorigin?: 'anonymous' | 'use-credentials';
19+
defer?: boolean;
20+
fetchpriority?: 'auto' | 'high' | 'low';
21+
importmap?: string;
22+
integrity?: string;
23+
nomodule?: boolean;
24+
nonce?: string;
25+
referrerpolicy?: ReferrerPolicy;
26+
src: string;
27+
type?: string;
28+
}
29+
30+
export interface IScriptConfig {
31+
attributes?: IScriptAttributes;
32+
loaded?: boolean;
33+
elementName?: string;
34+
}
35+
36+
@Injectable({
37+
providedIn: 'root'
38+
})
39+
export class ScriptInjectorService {
40+
41+
document: Document = inject(DOCUMENT);
42+
renderer: Renderer2 = inject(Renderer2);
43+
44+
#scriptStore = new Map<string, IScriptConfig>();
45+
46+
constructor() { }
47+
48+
public injectScript(src: string, scriptConfig: IScriptConfig = { elementName: 'head' }) {
49+
if (this.#scriptStore.has(src) && this.#scriptStore.get(src)?.loaded) {
50+
return;
51+
}
52+
const scriptAttributes: IScriptAttributes = { ...scriptConfig?.attributes, src };
53+
this.loadScript(src, scriptConfig = { ...scriptConfig, attributes: scriptAttributes }).then();
54+
}
55+
56+
loadScript(src: string, scriptConfig: IScriptConfig) {
57+
return new Promise((resolve, reject) => {
58+
const scriptElement = this.renderer.createElement('script');
59+
this.renderer.setAttribute(scriptElement, 'type', 'text/javascript');
60+
this.renderer.setAttribute(scriptElement, 'src', src);
61+
scriptElement.onload = () => {
62+
this.#scriptStore.set(src, { ...scriptConfig, loaded: true });
63+
resolve({ src: src, loaded: true });
64+
};
65+
scriptElement.onerror = (error: any) => reject({ src: src, loaded: false, error });
66+
this.renderer.appendChild(this.document.querySelector(scriptConfig.elementName ?? 'head'), scriptElement);
67+
});
68+
}
69+
}

0 commit comments

Comments
 (0)