Skip to content

Commit c2dd192

Browse files
committed
dev (#6)
* chore: bump dependencies to latest version * feat: add plugins support & organize files * npm: prepare new version * test: improve storage tests coverage & add helpers function to reduce duplication * demo: add plugins script to index.html * demo: update plugins config * fix: update types * feat: change default key && add jsdocs * feat: add kbd indicator & add jsdocs * feat: add eventListenerManager * ci: run tests on tag push * build: add prepublishOnly script * ci: prepare new release * fix: add missing pluginId * demo: remove card * feat: add `cooldown` option * feat: use lit web components * chore: bump dependencies to latest version & add demo script * build: update rollup config * feat: add decorators support * chore: reorganize folder structure
1 parent e6d191d commit c2dd192

11 files changed

Lines changed: 675 additions & 2 deletions

File tree

src/core/darkify.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Darkify } from '@/core/darkify';
2+
import { jest } from '@jest/globals';
3+
4+
describe('Darkify', () => {
5+
const setupMatchMedia = (isDark: boolean = false) => {
6+
Object.defineProperty(window, 'matchMedia', {
7+
writable: true,
8+
value: jest.fn().mockImplementation(query => ({
9+
matches: (query as string).includes('dark') ? isDark : false,
10+
media: query,
11+
onchange: null,
12+
addEventListener: jest.fn(),
13+
removeEventListener: jest.fn(),
14+
})),
15+
});
16+
};
17+
18+
const createButton = (id: string = 'el'): HTMLButtonElement => {
19+
const button = document.createElement('button');
20+
button.id = id;
21+
document.body.appendChild(button);
22+
return button;
23+
};
24+
25+
beforeAll(() => {
26+
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => undefined);
27+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null);
28+
jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => undefined);
29+
});
30+
31+
beforeEach(() => {
32+
document.body.innerHTML = '';
33+
jest.clearAllMocks();
34+
setupMatchMedia(true);
35+
});
36+
37+
afterEach(() => {
38+
document.body.innerHTML = '';
39+
});
40+
41+
describe('Initialization', () => {
42+
test('should initialize with dark theme when OS prefers dark', () => {
43+
createButton();
44+
const darkify = new Darkify('#el', {});
45+
expect(darkify.getCurrentTheme()).toBe('dark');
46+
});
47+
48+
test('should respect autoMatchTheme "false" option', () => {
49+
setupMatchMedia(false);
50+
createButton();
51+
const freshDarkify = new Darkify('#el', { autoMatchTheme: false });
52+
expect(freshDarkify.getCurrentTheme()).toBe('light');
53+
});
54+
});
55+
56+
describe('Storage', () => {
57+
test('should save theme to localStorage by default', () => {
58+
createButton();
59+
new Darkify('#el', {});
60+
expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
61+
});
62+
63+
test('should use sessionStorage when specified', () => {
64+
createButton('el2');
65+
new Darkify('#el2', { useStorage: 'session' });
66+
67+
expect(sessionStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
68+
expect(localStorage.removeItem).toHaveBeenCalledWith('theme');
69+
});
70+
71+
test('should not save theme when useStorage is "none"', () => {
72+
createButton('el3');
73+
new Darkify('#el3', { useStorage: 'none' });
74+
75+
expect(localStorage.setItem).not.toHaveBeenCalled();
76+
expect(sessionStorage.setItem).not.toHaveBeenCalled();
77+
});
78+
});
79+
80+
describe('Theme toggle', () => {
81+
test('should toggle theme from dark to light', () => {
82+
createButton();
83+
const darkify = new Darkify('#el', {});
84+
darkify.toggleTheme();
85+
expect(darkify.getCurrentTheme()).toBe('light');
86+
});
87+
88+
test('should toggle theme from light to dark', () => {
89+
setupMatchMedia(false);
90+
createButton();
91+
const freshDarkify = new Darkify('#el', {});
92+
freshDarkify.toggleTheme();
93+
expect(freshDarkify.getCurrentTheme()).toBe('dark');
94+
});
95+
});
96+
});

src/core/darkify.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { EventListenerManager } from '@/core/eventListenerManager';
2+
import { defaultOptions } from '@/core/defaultOptions';
3+
import { isBrowser } from '@/utils';
4+
import type { DarkifyPlugin, Options } from '@/types';
5+
6+
export class Darkify {
7+
private static readonly storageKey: string = 'theme';
8+
public readonly options: Options = defaultOptions;
9+
private plugins: DarkifyPlugin[] = [];
10+
public theme: string = 'light';
11+
private _elm!: EventListenerManager;
12+
private _meta!: HTMLMetaElement;
13+
private _style!: HTMLStyleElement;
14+
15+
/**
16+
* Creates a new Darkify instance with default options
17+
* @param element - Button ID (recommended) or HTML element selector
18+
*/
19+
constructor(element: string);
20+
21+
/**
22+
* Creates a new Darkify instance with custom options only
23+
* @param options - Options
24+
*/
25+
constructor(options: Partial<Options>);
26+
27+
/**
28+
* Creates a new Darkify instance for managing dark/light theme
29+
* @param element - Button ID (recommended) or HTML element selector
30+
* @param options - Options
31+
* @see {@link https://github.com/emrocode/darkify-js/wiki|Documentation}
32+
*/
33+
constructor(element: string, options: Partial<Options>);
34+
35+
constructor(element?: string | Partial<Options>, options?: Partial<Options>) {
36+
if (!isBrowser) return;
37+
38+
this._elm = new EventListenerManager();
39+
40+
const el = typeof element === 'string' ? element : undefined;
41+
const inputOpts =
42+
element && typeof element === 'object' ? (element as Partial<Options>) : options;
43+
const opts: Options = { ...defaultOptions, ...inputOpts };
44+
45+
this.options = opts;
46+
this.theme = this.getOsPreference();
47+
this._style = document.createElement('style');
48+
this._meta = document.createElement('meta');
49+
50+
this.createAttribute();
51+
this.init(el);
52+
this.syncThemeBetweenTabs();
53+
}
54+
55+
private init(element?: string): void {
56+
this._elm.addListener(
57+
window.matchMedia('(prefers-color-scheme: dark)'),
58+
'change',
59+
({ matches: isDark }: MediaQueryListEvent) => {
60+
this.theme = isDark ? 'dark' : 'light';
61+
this.createAttribute();
62+
}
63+
);
64+
65+
const setup = () => {
66+
this.initPlugins();
67+
const hasWidget = this.plugins.some(p => p.el !== undefined);
68+
if (element && !hasWidget) {
69+
const htmlElement = document.querySelector<HTMLElement>(element);
70+
if (htmlElement) {
71+
this._elm.addListener(htmlElement, 'click', () => this.toggleTheme());
72+
}
73+
}
74+
};
75+
76+
if (document.readyState !== 'loading') {
77+
return setup();
78+
}
79+
80+
this._elm.addListener(document, 'DOMContentLoaded', setup);
81+
}
82+
83+
private initPlugins(): void {
84+
this.options.usePlugins?.forEach(p => {
85+
const [Plugin, pluginOptions] = Array.isArray(p) ? p : [p, undefined];
86+
const plugin = new Plugin(this, pluginOptions);
87+
88+
const renderedNode = plugin.render();
89+
if (renderedNode) {
90+
plugin.el = renderedNode;
91+
}
92+
93+
this.plugins.push(plugin);
94+
});
95+
}
96+
97+
private notifyPlugins(theme: string) {
98+
this.plugins.forEach(plugin => {
99+
plugin.onThemeChange?.(theme);
100+
});
101+
}
102+
103+
private getStorage(): Storage | undefined {
104+
const { useStorage } = this.options;
105+
if (useStorage === 'none') return;
106+
return useStorage === 'local' ? window.localStorage : window.sessionStorage;
107+
}
108+
109+
private getOsPreference(): string {
110+
const storage = this.getStorage();
111+
112+
if (storage) {
113+
const stored = storage.getItem(Darkify.storageKey);
114+
if (stored) return stored;
115+
}
116+
117+
if (this.options.autoMatchTheme) {
118+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
119+
}
120+
121+
return 'light';
122+
}
123+
124+
private createAttribute(): void {
125+
const dataTheme = document.documentElement;
126+
const { useColorScheme } = this.options;
127+
128+
const css = `/**! Darkify / A simple dark mode toggle library **/\n:root:where([data-theme="${this.theme}"]),[data-theme="${this.theme}"]{color-scheme:${this.theme}}`;
129+
130+
dataTheme.dataset.theme = this.theme;
131+
132+
this.updateTags(css, useColorScheme);
133+
this.savePreference();
134+
}
135+
136+
private updateTags(css: string, useColorScheme: Options['useColorScheme']) {
137+
const [lightColor, darkColor] = useColorScheme;
138+
139+
this._meta.name = 'theme-color';
140+
this._meta.media = `(prefers-color-scheme: ${this.theme})`;
141+
this._meta.content = this.theme === 'light' ? lightColor : (darkColor ?? lightColor);
142+
this._style.innerHTML = css;
143+
144+
const head = document.head;
145+
146+
// avoid tags duplication
147+
if (!this._meta.parentNode) head.appendChild(this._meta);
148+
if (!this._style.parentNode) head.appendChild(this._style);
149+
}
150+
151+
private savePreference(): void {
152+
const { useStorage } = this.options;
153+
if (useStorage === 'none') return;
154+
const storage = useStorage === 'local';
155+
156+
const STO = storage ? window.localStorage : window.sessionStorage;
157+
const OTS = storage ? window.sessionStorage : window.localStorage;
158+
159+
OTS.removeItem(Darkify.storageKey);
160+
STO.setItem(Darkify.storageKey, this.theme);
161+
}
162+
163+
private syncThemeBetweenTabs(): void {
164+
this._elm.addListener(window, 'storage', (e: StorageEvent) => {
165+
if (e.key === Darkify.storageKey && e.newValue) {
166+
this.theme = e.newValue;
167+
this.createAttribute();
168+
this.notifyPlugins(e.newValue);
169+
}
170+
});
171+
}
172+
173+
private setTheme(newTheme: 'light' | 'dark'): void {
174+
this.theme = newTheme;
175+
this.createAttribute();
176+
this.notifyPlugins(newTheme);
177+
}
178+
179+
/**
180+
* Toggles the theme between light and dark modes
181+
*/
182+
toggleTheme(): void {
183+
this.setTheme(this.theme === 'light' ? 'dark' : 'light');
184+
}
185+
186+
/**
187+
* Retrieves the currently active theme
188+
* @returns The current theme name ('light' or 'dark')
189+
*/
190+
getCurrentTheme(): string {
191+
return this.theme;
192+
}
193+
194+
/**
195+
* Destroys the Darkify instance and cleans up all resources
196+
*
197+
* Removes all event listeners (system theme changes, click handlers, storage events),
198+
* destroys all active plugins, removes injected DOM elements (<style> and <meta> tags),
199+
* and frees associated resources.
200+
* Call this method when the instance is no longer needed to prevent memory leaks.
201+
*/
202+
destroy(): void {
203+
this._elm.clearListeners();
204+
205+
// remove injected DOM elements
206+
this._style?.parentNode?.removeChild(this._style);
207+
this._meta?.parentNode?.removeChild(this._meta);
208+
209+
// destroy plugins
210+
if (this.plugins.length > 0) {
211+
this.plugins.forEach(plugin => {
212+
plugin.onDestroy?.();
213+
});
214+
215+
this.plugins = [];
216+
}
217+
}
218+
}

src/core/eventListenerManager.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
type ListenerTarget = Window | Document | HTMLElement | MediaQueryList;
2+
3+
interface ListenerRecord {
4+
target: ListenerTarget;
5+
event: string;
6+
handler: EventListenerOrEventListenerObject;
7+
options?: boolean | AddEventListenerOptions;
8+
}
9+
10+
export class EventListenerManager {
11+
private listeners: ListenerRecord[] = [];
12+
13+
/**
14+
* Adds an event listener and tracks it for cleanup
15+
*/
16+
addListener<T extends Event>(
17+
target: ListenerTarget,
18+
event: string,
19+
handler: (event: T) => void,
20+
options?: boolean | AddEventListenerOptions
21+
): void;
22+
addListener(
23+
target: ListenerTarget,
24+
event: string,
25+
handler: EventListenerOrEventListenerObject,
26+
options?: boolean | AddEventListenerOptions
27+
): void;
28+
addListener(
29+
target: ListenerTarget,
30+
event: string,
31+
handler: EventListenerOrEventListenerObject,
32+
options?: boolean | AddEventListenerOptions
33+
): void {
34+
target.addEventListener(event, handler, options);
35+
this.listeners.push({ target, event, handler, options });
36+
}
37+
38+
/**
39+
* Removes all tracked event listeners
40+
*/
41+
clearListeners(): void {
42+
this.listeners.forEach(({ target, event, handler, options }) => {
43+
target.removeEventListener(event, handler, options);
44+
});
45+
this.listeners = [];
46+
}
47+
}

0 commit comments

Comments
 (0)