theme-watcher 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +182 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
> [!IMPORTANT]
|
|
2
|
+
> This program was written by GPT-5.3 Codex.
|
|
3
|
+
|
|
4
|
+
# theme-watcher
|
|
5
|
+
|
|
6
|
+
Plug-and-play theme syncing for React SPAs.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm i theme-watcher
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { useTheme, ThemeWatcher } from "theme-watcher";
|
|
18
|
+
|
|
19
|
+
function App() {
|
|
20
|
+
const { set, get } = useTheme();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<ThemeWatcher />
|
|
25
|
+
<button onClick={() => set("dark")}>Dark</button>
|
|
26
|
+
<pre>{get()}</pre>
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
### `<ThemeWatcher />`
|
|
35
|
+
|
|
36
|
+
Mount once near your app root.
|
|
37
|
+
|
|
38
|
+
Props:
|
|
39
|
+
- `theme?: "light" | "dark"` controlled override
|
|
40
|
+
- `storageKey?: string` default: `"theme-watcher"`
|
|
41
|
+
- `attribute?: "data-theme" | "class"` default: `"data-theme"`
|
|
42
|
+
- `defaultTheme?: "light" | "dark" | "system"` default: `"system"`
|
|
43
|
+
|
|
44
|
+
### `useTheme()`
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
- `theme` stored preference (`"light" | "dark" | "system"`)
|
|
48
|
+
- `resolvedTheme` active applied theme (`"light" | "dark"`)
|
|
49
|
+
- `source` where the current value came from (`"prop" | "storage" | "default" | "system"`)
|
|
50
|
+
- `set(theme)` set and persist preference
|
|
51
|
+
- `get()` get persisted preference
|
|
52
|
+
|
|
53
|
+
## Behavior notes
|
|
54
|
+
|
|
55
|
+
- Priority order: `theme prop` -> `localStorage` -> `defaultTheme` -> system theme.
|
|
56
|
+
- `system` mode updates live when `prefers-color-scheme` changes.
|
|
57
|
+
- Cross-tab updates are synced through `storage` events.
|
|
58
|
+
- Package is ESM-only.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
This repo uses Bun for package management and scripts, but the published package has no Bun runtime dependency.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bun install
|
|
66
|
+
bun run typecheck
|
|
67
|
+
bun run test
|
|
68
|
+
bun run build
|
|
69
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type Theme = "light" | "dark";
|
|
2
|
+
type ThemePreference = Theme | "system";
|
|
3
|
+
type ThemeSource = "prop" | "storage" | "default" | "system";
|
|
4
|
+
type ThemeAttribute = "data-theme" | "class";
|
|
5
|
+
interface ThemeState {
|
|
6
|
+
theme: ThemePreference;
|
|
7
|
+
resolvedTheme: Theme;
|
|
8
|
+
source: ThemeSource;
|
|
9
|
+
}
|
|
10
|
+
interface ThemeWatcherProps {
|
|
11
|
+
theme?: Theme;
|
|
12
|
+
storageKey?: string;
|
|
13
|
+
attribute?: ThemeAttribute;
|
|
14
|
+
defaultTheme?: ThemePreference;
|
|
15
|
+
}
|
|
16
|
+
interface ThemeApi extends ThemeState {
|
|
17
|
+
set: (theme: ThemePreference) => void;
|
|
18
|
+
get: () => ThemePreference;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare function ThemeWatcher({ theme, storageKey, attribute, defaultTheme }: ThemeWatcherProps): null;
|
|
22
|
+
|
|
23
|
+
declare function useTheme(): ThemeApi;
|
|
24
|
+
|
|
25
|
+
export { type Theme, type ThemeApi, type ThemeAttribute, type ThemePreference, type ThemeSource, type ThemeState, ThemeWatcher, type ThemeWatcherProps, useTheme };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/ThemeWatcher.tsx
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
// src/theme-store.ts
|
|
5
|
+
var DEFAULT_CONFIG = {
|
|
6
|
+
storageKey: "theme-watcher",
|
|
7
|
+
attribute: "data-theme",
|
|
8
|
+
defaultTheme: "system"
|
|
9
|
+
};
|
|
10
|
+
var VALID_PREFERENCES = /* @__PURE__ */ new Set(["light", "dark", "system"]);
|
|
11
|
+
var config = { ...DEFAULT_CONFIG };
|
|
12
|
+
var controlledTheme;
|
|
13
|
+
var state = {
|
|
14
|
+
theme: "system",
|
|
15
|
+
resolvedTheme: "light",
|
|
16
|
+
source: "system"
|
|
17
|
+
};
|
|
18
|
+
var subscribers = /* @__PURE__ */ new Set();
|
|
19
|
+
var mediaQueryList = null;
|
|
20
|
+
var initialized = false;
|
|
21
|
+
var watcherCount = 0;
|
|
22
|
+
function isBrowserReady() {
|
|
23
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
24
|
+
}
|
|
25
|
+
function readStoredTheme() {
|
|
26
|
+
if (!isBrowserReady()) return null;
|
|
27
|
+
try {
|
|
28
|
+
const value = window.localStorage.getItem(config.storageKey);
|
|
29
|
+
if (!value || !VALID_PREFERENCES.has(value)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeStoredTheme(theme) {
|
|
38
|
+
if (!isBrowserReady()) return;
|
|
39
|
+
try {
|
|
40
|
+
window.localStorage.setItem(config.storageKey, theme);
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function resolveSystemTheme() {
|
|
46
|
+
if (!isBrowserReady()) return "light";
|
|
47
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
48
|
+
}
|
|
49
|
+
function resolveTheme(propTheme, stored, defaultTheme) {
|
|
50
|
+
if (propTheme) {
|
|
51
|
+
return { theme: propTheme, resolvedTheme: propTheme, source: "prop" };
|
|
52
|
+
}
|
|
53
|
+
const effective = stored ?? defaultTheme;
|
|
54
|
+
if (effective === "system") {
|
|
55
|
+
return {
|
|
56
|
+
theme: "system",
|
|
57
|
+
resolvedTheme: resolveSystemTheme(),
|
|
58
|
+
source: stored ? "storage" : defaultTheme === "system" ? "system" : "default"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const source = stored ? "storage" : "default";
|
|
62
|
+
return { theme: effective, resolvedTheme: effective, source };
|
|
63
|
+
}
|
|
64
|
+
function applyDomTheme(resolvedTheme) {
|
|
65
|
+
if (!isBrowserReady()) return;
|
|
66
|
+
const root = document.documentElement;
|
|
67
|
+
if (config.attribute === "class") {
|
|
68
|
+
root.classList.toggle("dark", resolvedTheme === "dark");
|
|
69
|
+
root.classList.toggle("light", resolvedTheme === "light");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
root.setAttribute("data-theme", resolvedTheme);
|
|
73
|
+
}
|
|
74
|
+
function emit() {
|
|
75
|
+
for (const callback of subscribers) {
|
|
76
|
+
callback();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function recompute() {
|
|
80
|
+
const stored = readStoredTheme();
|
|
81
|
+
state = resolveTheme(controlledTheme, stored, config.defaultTheme);
|
|
82
|
+
applyDomTheme(state.resolvedTheme);
|
|
83
|
+
emit();
|
|
84
|
+
}
|
|
85
|
+
function handleMediaChange() {
|
|
86
|
+
if (controlledTheme || state.theme !== "system") return;
|
|
87
|
+
recompute();
|
|
88
|
+
}
|
|
89
|
+
function handleStorage(event) {
|
|
90
|
+
if (event.key !== config.storageKey) return;
|
|
91
|
+
recompute();
|
|
92
|
+
}
|
|
93
|
+
function attachListeners() {
|
|
94
|
+
if (!isBrowserReady() || mediaQueryList) return;
|
|
95
|
+
mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
|
|
96
|
+
mediaQueryList.addEventListener("change", handleMediaChange);
|
|
97
|
+
window.addEventListener("storage", handleStorage);
|
|
98
|
+
}
|
|
99
|
+
function detachListeners() {
|
|
100
|
+
if (!isBrowserReady() || !mediaQueryList) return;
|
|
101
|
+
mediaQueryList.removeEventListener("change", handleMediaChange);
|
|
102
|
+
window.removeEventListener("storage", handleStorage);
|
|
103
|
+
mediaQueryList = null;
|
|
104
|
+
}
|
|
105
|
+
function configureStore(nextConfig, propTheme) {
|
|
106
|
+
config = {
|
|
107
|
+
...config,
|
|
108
|
+
...nextConfig
|
|
109
|
+
};
|
|
110
|
+
controlledTheme = propTheme;
|
|
111
|
+
recompute();
|
|
112
|
+
}
|
|
113
|
+
function mountWatcher(nextConfig, propTheme) {
|
|
114
|
+
watcherCount += 1;
|
|
115
|
+
if (!initialized) {
|
|
116
|
+
initialized = true;
|
|
117
|
+
attachListeners();
|
|
118
|
+
}
|
|
119
|
+
configureStore(nextConfig, propTheme);
|
|
120
|
+
return () => {
|
|
121
|
+
watcherCount -= 1;
|
|
122
|
+
if (watcherCount <= 0) {
|
|
123
|
+
watcherCount = 0;
|
|
124
|
+
controlledTheme = void 0;
|
|
125
|
+
detachListeners();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function subscribe(callback) {
|
|
130
|
+
subscribers.add(callback);
|
|
131
|
+
return () => {
|
|
132
|
+
subscribers.delete(callback);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function setTheme(theme) {
|
|
136
|
+
if (!VALID_PREFERENCES.has(theme)) return;
|
|
137
|
+
writeStoredTheme(theme);
|
|
138
|
+
state = resolveTheme(controlledTheme, theme, config.defaultTheme);
|
|
139
|
+
applyDomTheme(state.resolvedTheme);
|
|
140
|
+
emit();
|
|
141
|
+
}
|
|
142
|
+
function getTheme() {
|
|
143
|
+
return readStoredTheme() ?? config.defaultTheme;
|
|
144
|
+
}
|
|
145
|
+
function getState() {
|
|
146
|
+
return state;
|
|
147
|
+
}
|
|
148
|
+
function getServerSnapshot() {
|
|
149
|
+
return {
|
|
150
|
+
theme: config.defaultTheme,
|
|
151
|
+
resolvedTheme: config.defaultTheme === "dark" ? "dark" : "light",
|
|
152
|
+
source: "default"
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/ThemeWatcher.tsx
|
|
157
|
+
function ThemeWatcher({
|
|
158
|
+
theme,
|
|
159
|
+
storageKey = "theme-watcher",
|
|
160
|
+
attribute = "data-theme",
|
|
161
|
+
defaultTheme = "system"
|
|
162
|
+
}) {
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
return mountWatcher({ storageKey, attribute, defaultTheme }, theme);
|
|
165
|
+
}, [theme, storageKey, attribute, defaultTheme]);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/useTheme.ts
|
|
170
|
+
import { useSyncExternalStore } from "react";
|
|
171
|
+
function useTheme() {
|
|
172
|
+
const state2 = useSyncExternalStore(subscribe, getState, getServerSnapshot);
|
|
173
|
+
return {
|
|
174
|
+
...state2,
|
|
175
|
+
set: setTheme,
|
|
176
|
+
get: getTheme
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export {
|
|
180
|
+
ThemeWatcher,
|
|
181
|
+
useTheme
|
|
182
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "theme-watcher",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Plug-and-play theme watcher for React SPAs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@testing-library/react": "^16.3.0",
|
|
29
|
+
"@types/react": "^19.1.11",
|
|
30
|
+
"@types/react-dom": "^19.1.7",
|
|
31
|
+
"jsdom": "^26.1.0",
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"typescript": "^5.9.2",
|
|
34
|
+
"vitest": "^3.2.4"
|
|
35
|
+
}
|
|
36
|
+
}
|