nualt-theme-toggle 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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +239 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/styles.css +102 -0
- package/dist/theme-toggle.d.ts +19 -0
- package/dist/theme-toggle.d.ts.map +1 -0
- package/dist/theme-toggle.js +102 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Sarazin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# nualt-theme-toggle
|
|
2
|
+
|
|
3
|
+
A dev-only theme toggle for Next.js projects.
|
|
4
|
+
|
|
5
|
+
It lets you force light or dark mode while building a page, without opening Chrome DevTools or changing the OS theme.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
From a Next.js App Router project:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm dlx nualt-theme-toggle init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx nualt-theme-toggle init
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The initializer:
|
|
22
|
+
|
|
23
|
+
- installs `nualt-theme-toggle` if it is missing
|
|
24
|
+
- imports `nualt-theme-toggle/styles.css`
|
|
25
|
+
- renders `<ThemeToggle />` in `app/layout` or `src/app/layout`
|
|
26
|
+
- adds the Tailwind v4 class-based dark variant
|
|
27
|
+
- adds `:root.light` / `:root.dark` overrides when the default Next theme variables are present
|
|
28
|
+
|
|
29
|
+
Then run your dev server, press `t` to switch theme, and press `Shift+H` to hide or show the floating button.
|
|
30
|
+
|
|
31
|
+
## Local Tarball Test
|
|
32
|
+
|
|
33
|
+
Before publishing to npm:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd /Users/thomassarazin/Lab/nualt-theme-toggle
|
|
37
|
+
pnpm install
|
|
38
|
+
pnpm build
|
|
39
|
+
pnpm pack
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
In a fresh Next.js project:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm add /Users/thomassarazin/Lab/nualt-theme-toggle/nualt-theme-toggle-0.1.0.tgz
|
|
46
|
+
pnpm exec nualt-theme-toggle init --no-install
|
|
47
|
+
pnpm dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Manual Usage
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { ThemeToggle } from "nualt-theme-toggle";
|
|
54
|
+
import "nualt-theme-toggle/styles.css";
|
|
55
|
+
|
|
56
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<html lang="en">
|
|
59
|
+
<body>
|
|
60
|
+
{children}
|
|
61
|
+
<ThemeToggle />
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For Tailwind v4, your global CSS must include:
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If your app uses the default Next variables from `create-next-app`, keep system mode as the default and add forced overrides:
|
|
75
|
+
|
|
76
|
+
```css
|
|
77
|
+
:root.light {
|
|
78
|
+
--background: #ffffff;
|
|
79
|
+
--foreground: #171717;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
:root.dark {
|
|
83
|
+
--background: #0a0a0a;
|
|
84
|
+
--foreground: #ededed;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The `init` command does this for the default template.
|
|
89
|
+
|
|
90
|
+
## API
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
type ThemeToggleProps = {
|
|
94
|
+
buttonClassName?: string;
|
|
95
|
+
className?: string;
|
|
96
|
+
enabledInProduction?: boolean;
|
|
97
|
+
labels?: {
|
|
98
|
+
light?: string;
|
|
99
|
+
dark?: string;
|
|
100
|
+
};
|
|
101
|
+
shortcut?: string | false;
|
|
102
|
+
showFloatingButton?: boolean;
|
|
103
|
+
visibilityShortcut?: string | false;
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Defaults:
|
|
108
|
+
|
|
109
|
+
- dev-only
|
|
110
|
+
- shortcut: `t`
|
|
111
|
+
- visibility shortcut: `Shift+H`
|
|
112
|
+
- root classes: `html.light` / `html.dark`
|
|
113
|
+
- no localStorage persistence
|
|
114
|
+
- system preference stays active until you force a mode
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
const PACKAGE_NAME = "nualt-theme-toggle";
|
|
6
|
+
const command = process.argv[2] ?? "help";
|
|
7
|
+
function printHelp() {
|
|
8
|
+
console.log(`nualt-theme-toggle
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
nualt-theme-toggle init [options]
|
|
12
|
+
nualt-theme-toggle help
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--cwd <path> Project directory. Defaults to the current directory.
|
|
16
|
+
--no-install Skip dependency installation.
|
|
17
|
+
--package <spec> Package specifier to install. Defaults to nualt-theme-toggle.
|
|
18
|
+
--dry-run Print what would change without writing files.
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
function parseInitOptions(args) {
|
|
22
|
+
const options = {
|
|
23
|
+
cwd: process.cwd(),
|
|
24
|
+
dryRun: false,
|
|
25
|
+
install: true,
|
|
26
|
+
packageSpecifier: PACKAGE_NAME,
|
|
27
|
+
};
|
|
28
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
29
|
+
const arg = args[index];
|
|
30
|
+
if (arg === "--cwd") {
|
|
31
|
+
const value = args[index + 1];
|
|
32
|
+
if (!value)
|
|
33
|
+
throw new Error("Missing value for --cwd.");
|
|
34
|
+
options.cwd = resolve(value);
|
|
35
|
+
index += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === "--package") {
|
|
39
|
+
const value = args[index + 1];
|
|
40
|
+
if (!value)
|
|
41
|
+
throw new Error("Missing value for --package.");
|
|
42
|
+
options.packageSpecifier = value;
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg === "--no-install") {
|
|
47
|
+
options.install = false;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (arg === "--dry-run") {
|
|
51
|
+
options.dryRun = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
55
|
+
}
|
|
56
|
+
return options;
|
|
57
|
+
}
|
|
58
|
+
function readPackageJson(cwd) {
|
|
59
|
+
const filePath = join(cwd, "package.json");
|
|
60
|
+
if (!existsSync(filePath)) {
|
|
61
|
+
throw new Error(`No package.json found in ${cwd}.`);
|
|
62
|
+
}
|
|
63
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
64
|
+
}
|
|
65
|
+
function hasDependency(packageJson) {
|
|
66
|
+
return Boolean(packageJson.dependencies?.[PACKAGE_NAME] ||
|
|
67
|
+
packageJson.devDependencies?.[PACKAGE_NAME] ||
|
|
68
|
+
packageJson.peerDependencies?.[PACKAGE_NAME]);
|
|
69
|
+
}
|
|
70
|
+
function detectPackageManager(cwd) {
|
|
71
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml")))
|
|
72
|
+
return "pnpm";
|
|
73
|
+
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) {
|
|
74
|
+
return "bun";
|
|
75
|
+
}
|
|
76
|
+
if (existsSync(join(cwd, "yarn.lock")))
|
|
77
|
+
return "yarn";
|
|
78
|
+
return "npm";
|
|
79
|
+
}
|
|
80
|
+
function installPackage(cwd, packageSpecifier) {
|
|
81
|
+
const packageManager = detectPackageManager(cwd);
|
|
82
|
+
const argsByManager = {
|
|
83
|
+
bun: ["add", packageSpecifier],
|
|
84
|
+
npm: ["install", packageSpecifier],
|
|
85
|
+
pnpm: ["add", packageSpecifier],
|
|
86
|
+
yarn: ["add", packageSpecifier],
|
|
87
|
+
};
|
|
88
|
+
console.log(`Installing ${packageSpecifier} with ${packageManager}...`);
|
|
89
|
+
const result = spawnSync(packageManager, argsByManager[packageManager], {
|
|
90
|
+
cwd,
|
|
91
|
+
shell: process.platform === "win32",
|
|
92
|
+
stdio: "inherit",
|
|
93
|
+
});
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
throw new Error(`Failed to install ${packageSpecifier}.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function findLayoutFile(cwd) {
|
|
99
|
+
const candidates = [
|
|
100
|
+
"app/layout.tsx",
|
|
101
|
+
"app/layout.jsx",
|
|
102
|
+
"app/layout.ts",
|
|
103
|
+
"app/layout.js",
|
|
104
|
+
"src/app/layout.tsx",
|
|
105
|
+
"src/app/layout.jsx",
|
|
106
|
+
"src/app/layout.ts",
|
|
107
|
+
"src/app/layout.js",
|
|
108
|
+
];
|
|
109
|
+
return candidates.map((candidate) => join(cwd, candidate)).find(existsSync);
|
|
110
|
+
}
|
|
111
|
+
function findGlobalCssFile(cwd, layoutFile) {
|
|
112
|
+
if (layoutFile) {
|
|
113
|
+
const layoutSource = readFileSync(layoutFile, "utf8");
|
|
114
|
+
const cssImport = layoutSource.match(/import\s+["'](\.\/?[^"']*\.css)["'];?/);
|
|
115
|
+
if (cssImport?.[1]) {
|
|
116
|
+
const resolved = resolve(dirname(layoutFile), cssImport[1]);
|
|
117
|
+
if (existsSync(resolved))
|
|
118
|
+
return resolved;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const candidates = [
|
|
122
|
+
"app/globals.css",
|
|
123
|
+
"src/app/globals.css",
|
|
124
|
+
"styles/globals.css",
|
|
125
|
+
];
|
|
126
|
+
return candidates.map((candidate) => join(cwd, candidate)).find(existsSync);
|
|
127
|
+
}
|
|
128
|
+
function addImport(source, importLine) {
|
|
129
|
+
if (source.includes(importLine))
|
|
130
|
+
return source;
|
|
131
|
+
const directive = source.match(/^["']use [^"']+["'];?\n+/);
|
|
132
|
+
const insertionPoint = directive ? directive[0].length : 0;
|
|
133
|
+
return `${source.slice(0, insertionPoint)}${importLine}\n${source.slice(insertionPoint)}`;
|
|
134
|
+
}
|
|
135
|
+
function patchLayoutSource(source) {
|
|
136
|
+
let nextSource = source;
|
|
137
|
+
nextSource = addImport(nextSource, `import { ThemeToggle } from "${PACKAGE_NAME}";`);
|
|
138
|
+
nextSource = addImport(nextSource, `import "${PACKAGE_NAME}/styles.css";`);
|
|
139
|
+
if (nextSource.includes("<ThemeToggle"))
|
|
140
|
+
return nextSource;
|
|
141
|
+
const oneLineBody = /<body([^>]*)>\s*\{children\}\s*<\/body>/;
|
|
142
|
+
if (oneLineBody.test(nextSource)) {
|
|
143
|
+
return nextSource.replace(oneLineBody, `<body$1>\n {children}\n <ThemeToggle />\n </body>`);
|
|
144
|
+
}
|
|
145
|
+
const closingBodyIndex = nextSource.lastIndexOf("</body>");
|
|
146
|
+
if (closingBodyIndex === -1) {
|
|
147
|
+
throw new Error("Could not find a <body> tag in the Next.js root layout.");
|
|
148
|
+
}
|
|
149
|
+
return `${nextSource.slice(0, closingBodyIndex)} <ThemeToggle />\n ${nextSource.slice(closingBodyIndex)}`;
|
|
150
|
+
}
|
|
151
|
+
function addTailwindClassDarkVariant(source) {
|
|
152
|
+
if (source.includes("@custom-variant dark"))
|
|
153
|
+
return source;
|
|
154
|
+
if (source.includes('@import "tailwindcss";')) {
|
|
155
|
+
return source.replace('@import "tailwindcss";', '@import "tailwindcss";\n@custom-variant dark (&:where(.dark, .dark *));');
|
|
156
|
+
}
|
|
157
|
+
if (source.includes("@import 'tailwindcss';")) {
|
|
158
|
+
return source.replace("@import 'tailwindcss';", "@import 'tailwindcss';\n@custom-variant dark (&:where(.dark, .dark *));");
|
|
159
|
+
}
|
|
160
|
+
return source;
|
|
161
|
+
}
|
|
162
|
+
function extractRootVariables(source) {
|
|
163
|
+
const lightMatch = source.match(/:root\s*\{([\s\S]*?)\n\}/m);
|
|
164
|
+
const darkMatch = source.match(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([\s\S]*?)\n\s*\}\s*\n\s*\}/m);
|
|
165
|
+
if (!lightMatch?.[1] || !darkMatch?.[1])
|
|
166
|
+
return null;
|
|
167
|
+
return { dark: darkMatch[1], light: lightMatch[1] };
|
|
168
|
+
}
|
|
169
|
+
function addForcedRootVariables(source) {
|
|
170
|
+
if (source.includes(":root.light") && source.includes(":root.dark")) {
|
|
171
|
+
return source;
|
|
172
|
+
}
|
|
173
|
+
const variables = extractRootVariables(source);
|
|
174
|
+
if (!variables)
|
|
175
|
+
return source;
|
|
176
|
+
return `${source.trimEnd()}\n\n:root.light {${variables.light}\n}\n\n:root.dark {${variables.dark}\n}\n`;
|
|
177
|
+
}
|
|
178
|
+
function addColorSchemeFallback(source) {
|
|
179
|
+
if (source.includes("html.light") || source.includes("color-scheme: light")) {
|
|
180
|
+
return source;
|
|
181
|
+
}
|
|
182
|
+
return `${source.trimEnd()}\n\nhtml.light {\n color-scheme: light;\n}\n\nhtml.dark {\n color-scheme: dark;\n}\n`;
|
|
183
|
+
}
|
|
184
|
+
function patchCssSource(source) {
|
|
185
|
+
const withTailwindVariant = addTailwindClassDarkVariant(source);
|
|
186
|
+
const withForcedRootVariables = addForcedRootVariables(withTailwindVariant);
|
|
187
|
+
return addColorSchemeFallback(withForcedRootVariables);
|
|
188
|
+
}
|
|
189
|
+
function writeIfChanged(filePath, nextSource, dryRun) {
|
|
190
|
+
const currentSource = readFileSync(filePath, "utf8");
|
|
191
|
+
if (currentSource === nextSource) {
|
|
192
|
+
console.log(`Already configured: ${filePath}`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!dryRun)
|
|
196
|
+
writeFileSync(filePath, nextSource);
|
|
197
|
+
console.log(`${dryRun ? "Would update" : "Updated"}: ${filePath}`);
|
|
198
|
+
}
|
|
199
|
+
function init() {
|
|
200
|
+
const options = parseInitOptions(process.argv.slice(3));
|
|
201
|
+
const packageJson = readPackageJson(options.cwd);
|
|
202
|
+
if (!packageJson.dependencies?.next && !packageJson.devDependencies?.next) {
|
|
203
|
+
throw new Error("This initializer is for Next.js projects.");
|
|
204
|
+
}
|
|
205
|
+
if (options.install && !hasDependency(packageJson)) {
|
|
206
|
+
if (options.dryRun) {
|
|
207
|
+
console.log(`Would install ${options.packageSpecifier}.`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
installPackage(options.cwd, options.packageSpecifier);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const layoutFile = findLayoutFile(options.cwd);
|
|
214
|
+
if (!layoutFile) {
|
|
215
|
+
throw new Error("Could not find app/layout.tsx or src/app/layout.tsx.");
|
|
216
|
+
}
|
|
217
|
+
const cssFile = findGlobalCssFile(options.cwd, layoutFile);
|
|
218
|
+
writeIfChanged(layoutFile, patchLayoutSource(readFileSync(layoutFile, "utf8")), options.dryRun);
|
|
219
|
+
if (cssFile) {
|
|
220
|
+
writeIfChanged(cssFile, patchCssSource(readFileSync(cssFile, "utf8")), options.dryRun);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.warn("No global CSS file found. Add the Tailwind dark variant manually.");
|
|
224
|
+
}
|
|
225
|
+
console.log("nualt-theme-toggle is ready. Run dev and press t.");
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
if (command === "init") {
|
|
229
|
+
init();
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
printHelp();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
237
|
+
console.error(`nualt-theme-toggle: ${message}`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,kBAAkB,EAClB,WAAW,EACX,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,GACtB,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
package/dist/styles.css
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
.nualt-theme-toggle {
|
|
2
|
+
--nualt-theme-toggle-bg: color-mix(
|
|
3
|
+
in srgb,
|
|
4
|
+
var(--background, #fff) 86%,
|
|
5
|
+
transparent
|
|
6
|
+
);
|
|
7
|
+
--nualt-theme-toggle-color: var(--foreground, #111);
|
|
8
|
+
--nualt-theme-toggle-border: color-mix(
|
|
9
|
+
in srgb,
|
|
10
|
+
var(--foreground, #111) 16%,
|
|
11
|
+
transparent
|
|
12
|
+
);
|
|
13
|
+
--nualt-theme-toggle-shadow:
|
|
14
|
+
0 18px 45px rgb(0 0 0 / 0.16),
|
|
15
|
+
inset 0 1px 0 rgb(255 255 255 / 0.12);
|
|
16
|
+
bottom: 1.5rem;
|
|
17
|
+
position: fixed;
|
|
18
|
+
right: 1.5rem;
|
|
19
|
+
z-index: 2147483647;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.nualt-theme-toggle__button {
|
|
23
|
+
align-items: center;
|
|
24
|
+
backdrop-filter: blur(14px);
|
|
25
|
+
-webkit-backdrop-filter: blur(14px);
|
|
26
|
+
background: var(--nualt-theme-toggle-bg);
|
|
27
|
+
border: 1px solid var(--nualt-theme-toggle-border);
|
|
28
|
+
border-radius: 999px;
|
|
29
|
+
box-shadow: var(--nualt-theme-toggle-shadow);
|
|
30
|
+
color: var(--nualt-theme-toggle-color);
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
font: 650 0.8125rem/1.1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
33
|
+
sans-serif;
|
|
34
|
+
gap: 0.625rem;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
min-height: 2.625rem;
|
|
37
|
+
padding: 0.4375rem 0.5rem 0.4375rem 0.875rem;
|
|
38
|
+
transition:
|
|
39
|
+
border-color 160ms ease,
|
|
40
|
+
box-shadow 160ms ease,
|
|
41
|
+
transform 160ms ease;
|
|
42
|
+
width: 9.75rem;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.nualt-theme-toggle__button:hover {
|
|
46
|
+
--nualt-theme-toggle-border: color-mix(
|
|
47
|
+
in srgb,
|
|
48
|
+
var(--foreground, #111) 28%,
|
|
49
|
+
transparent
|
|
50
|
+
);
|
|
51
|
+
transform: translateY(-1px);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.nualt-theme-toggle__button:active {
|
|
55
|
+
transform: translateY(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.nualt-theme-toggle__button:focus-visible {
|
|
59
|
+
outline: 2px solid currentColor;
|
|
60
|
+
outline-offset: 3px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.nualt-theme-toggle__label {
|
|
64
|
+
display: block;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
text-align: left;
|
|
67
|
+
text-overflow: ellipsis;
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.nualt-theme-toggle__shortcut {
|
|
72
|
+
align-items: center;
|
|
73
|
+
background:
|
|
74
|
+
linear-gradient(
|
|
75
|
+
180deg,
|
|
76
|
+
color-mix(in srgb, var(--background, #fff) 92%, currentColor),
|
|
77
|
+
color-mix(in srgb, var(--background, #fff) 82%, currentColor)
|
|
78
|
+
);
|
|
79
|
+
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
|
80
|
+
border-radius: 0.5rem;
|
|
81
|
+
box-shadow:
|
|
82
|
+
inset 0 1px 0 rgb(255 255 255 / 0.24),
|
|
83
|
+
inset 0 -1px 0 rgb(0 0 0 / 0.08),
|
|
84
|
+
0 1px 1px rgb(0 0 0 / 0.08);
|
|
85
|
+
color: var(--nualt-theme-toggle-color);
|
|
86
|
+
display: inline-flex;
|
|
87
|
+
font: inherit;
|
|
88
|
+
font-size: 0.75rem;
|
|
89
|
+
font-weight: 750;
|
|
90
|
+
height: 1.75rem;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
letter-spacing: 0;
|
|
93
|
+
min-width: 1.75rem;
|
|
94
|
+
padding: 0 0.5rem;
|
|
95
|
+
text-transform: uppercase;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@media (prefers-reduced-motion: reduce) {
|
|
99
|
+
.nualt-theme-toggle__button {
|
|
100
|
+
transition: none;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes } from "react";
|
|
2
|
+
export type ThemeToggleTheme = "dark" | "light";
|
|
3
|
+
export type ThemeToggleLabels = {
|
|
4
|
+
dark?: string;
|
|
5
|
+
light?: string;
|
|
6
|
+
};
|
|
7
|
+
export type ThemeToggleProps = {
|
|
8
|
+
buttonClassName?: string;
|
|
9
|
+
buttonProps?: Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "aria-pressed" | "children" | "className" | "onClick" | "type">;
|
|
10
|
+
className?: string;
|
|
11
|
+
enabledInProduction?: boolean;
|
|
12
|
+
labels?: ThemeToggleLabels;
|
|
13
|
+
shortcut?: string | false;
|
|
14
|
+
showFloatingButton?: boolean;
|
|
15
|
+
visibilityShortcut?: string | false;
|
|
16
|
+
};
|
|
17
|
+
export declare function ThemeToggle({ buttonClassName, buttonProps, className, enabledInProduction, labels, shortcut, showFloatingButton, visibilityShortcut, }: ThemeToggleProps): import("react/jsx-runtime").JSX.Element | null;
|
|
18
|
+
export declare const NualtThemeDevTools: typeof ThemeToggle;
|
|
19
|
+
//# sourceMappingURL=theme-toggle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"theme-toggle.d.ts","sourceRoot":"","sources":["../src/theme-toggle.tsx"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,oBAAoB,EAC1B,MAAM,OAAO,CAAC;AAEf,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,CAAC;AAEhD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,IAAI,CAChB,oBAAoB,CAAC,iBAAiB,CAAC,EACvC,YAAY,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,CAC9E,CAAC;IACF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC1B,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACrC,CAAC;AAoCF,wBAAgB,WAAW,CAAC,EAC1B,eAAoB,EACpB,WAAW,EACX,SAAc,EACd,mBAA2B,EAC3B,MAAM,EACN,QAAc,EACd,kBAAyB,EACzB,kBAAwB,GACzB,EAAE,gBAAgB,kDA6FlB;AAED,eAAO,MAAM,kBAAkB,oBAAc,CAAC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useState, useSyncExternalStore, } from "react";
|
|
4
|
+
const defaultLabels = {
|
|
5
|
+
dark: "Dark mode",
|
|
6
|
+
light: "Light mode",
|
|
7
|
+
};
|
|
8
|
+
function getSystemTheme() {
|
|
9
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
10
|
+
? "dark"
|
|
11
|
+
: "light";
|
|
12
|
+
}
|
|
13
|
+
function subscribeToSystemTheme(onStoreChange) {
|
|
14
|
+
const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
15
|
+
colorScheme.addEventListener("change", onStoreChange);
|
|
16
|
+
return () => {
|
|
17
|
+
colorScheme.removeEventListener("change", onStoreChange);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function isEditableTarget(target) {
|
|
21
|
+
if (target instanceof HTMLInputElement)
|
|
22
|
+
return true;
|
|
23
|
+
if (target instanceof HTMLTextAreaElement)
|
|
24
|
+
return true;
|
|
25
|
+
if (target instanceof HTMLSelectElement)
|
|
26
|
+
return true;
|
|
27
|
+
if (target instanceof HTMLElement && target.isContentEditable)
|
|
28
|
+
return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
function normalizeShortcut(shortcut) {
|
|
32
|
+
if (!shortcut)
|
|
33
|
+
return false;
|
|
34
|
+
return shortcut.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
export function ThemeToggle({ buttonClassName = "", buttonProps, className = "", enabledInProduction = false, labels, shortcut = "t", showFloatingButton = true, visibilityShortcut = "h", }) {
|
|
37
|
+
const enabled = enabledInProduction || process.env.NODE_ENV !== "production";
|
|
38
|
+
const systemTheme = useSyncExternalStore(subscribeToSystemTheme, getSystemTheme, () => "light");
|
|
39
|
+
const [themeOverride, setThemeOverride] = useState(null);
|
|
40
|
+
const theme = themeOverride ?? systemTheme;
|
|
41
|
+
const isDark = theme === "dark";
|
|
42
|
+
const shortcutKey = normalizeShortcut(shortcut);
|
|
43
|
+
const visibilityShortcutKey = normalizeShortcut(visibilityShortcut);
|
|
44
|
+
const resolvedLabels = { ...defaultLabels, ...labels };
|
|
45
|
+
const [buttonVisible, setButtonVisible] = useState(true);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!enabled)
|
|
48
|
+
return;
|
|
49
|
+
document.documentElement.classList.toggle("dark", themeOverride === "dark");
|
|
50
|
+
document.documentElement.classList.toggle("light", themeOverride === "light");
|
|
51
|
+
}, [enabled, themeOverride]);
|
|
52
|
+
const toggleTheme = useCallback(() => {
|
|
53
|
+
setThemeOverride((currentTheme) => {
|
|
54
|
+
const activeTheme = currentTheme ?? systemTheme;
|
|
55
|
+
return activeTheme === "dark" ? "light" : "dark";
|
|
56
|
+
});
|
|
57
|
+
}, [systemTheme]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!enabled || !shortcutKey)
|
|
60
|
+
return;
|
|
61
|
+
function onKeyDown(event) {
|
|
62
|
+
if (!event.key)
|
|
63
|
+
return;
|
|
64
|
+
if (event.key.toLowerCase() !== shortcutKey)
|
|
65
|
+
return;
|
|
66
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
67
|
+
return;
|
|
68
|
+
if (isEditableTarget(event.target))
|
|
69
|
+
return;
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
toggleTheme();
|
|
72
|
+
}
|
|
73
|
+
window.addEventListener("keydown", onKeyDown);
|
|
74
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
75
|
+
}, [enabled, shortcutKey, toggleTheme]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!enabled || !visibilityShortcutKey)
|
|
78
|
+
return;
|
|
79
|
+
function onKeyDown(event) {
|
|
80
|
+
if (!event.key)
|
|
81
|
+
return;
|
|
82
|
+
if (event.key.toLowerCase() !== visibilityShortcutKey)
|
|
83
|
+
return;
|
|
84
|
+
if (!event.shiftKey)
|
|
85
|
+
return;
|
|
86
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
87
|
+
return;
|
|
88
|
+
if (isEditableTarget(event.target))
|
|
89
|
+
return;
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
setButtonVisible((visible) => !visible);
|
|
92
|
+
}
|
|
93
|
+
window.addEventListener("keydown", onKeyDown);
|
|
94
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
95
|
+
}, [enabled, visibilityShortcutKey]);
|
|
96
|
+
if (!enabled || !showFloatingButton || !buttonVisible)
|
|
97
|
+
return null;
|
|
98
|
+
return (_jsx("div", { className: ["nualt-theme-toggle", className].filter(Boolean).join(" "), children: _jsxs("button", { ...buttonProps, type: "button", onClick: toggleTheme, className: ["nualt-theme-toggle__button", buttonClassName]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(" "), "aria-label": "Toggle light and dark mode with the T key. Press Shift+H to hide or show this button.", "aria-pressed": isDark, children: [_jsx("span", { className: "nualt-theme-toggle__label", children: isDark ? resolvedLabels.dark : resolvedLabels.light }), shortcutKey ? (_jsx("kbd", { className: "nualt-theme-toggle__shortcut", children: shortcutKey.toUpperCase() })) : null] }) }));
|
|
101
|
+
}
|
|
102
|
+
export const NualtThemeDevTools = ThemeToggle;
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nualt-theme-toggle",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A dev-only theme toggle for Next.js projects.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Thomas Sarazin",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"packageManager": "pnpm@11.1.2",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/thomassarazin/nualt-theme-toggle.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/thomassarazin/nualt-theme-toggle/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/thomassarazin/nualt-theme-toggle#readme",
|
|
17
|
+
"sideEffects": [
|
|
18
|
+
"./dist/styles.css"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./styles.css": "./dist/styles.css",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"nualt-theme-toggle": "dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
39
|
+
"pack:local": "pnpm pack"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^24.10.1",
|
|
46
|
+
"@types/react": "^19.2.7",
|
|
47
|
+
"typescript": "^5.9.3"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"nextjs",
|
|
51
|
+
"react",
|
|
52
|
+
"theme",
|
|
53
|
+
"dark-mode",
|
|
54
|
+
"tailwindcss",
|
|
55
|
+
"devtools"
|
|
56
|
+
]
|
|
57
|
+
}
|