@philiprehberger/react-theme-provider 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 +62 -0
- package/dist/index.cjs +89 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @philiprehberger/react-theme-provider
|
|
2
|
+
|
|
3
|
+
Dark/light/system theme provider for React with localStorage persistence and system preference detection.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @philiprehberger/react-theme-provider
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### ThemeProvider
|
|
14
|
+
|
|
15
|
+
Wrap your app with the provider:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { ThemeProvider } from '@philiprehberger/react-theme-provider';
|
|
19
|
+
|
|
20
|
+
function App() {
|
|
21
|
+
return (
|
|
22
|
+
<ThemeProvider defaultTheme="system" storageKey="theme">
|
|
23
|
+
<YourApp />
|
|
24
|
+
</ThemeProvider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### useTheme
|
|
30
|
+
|
|
31
|
+
Access theme state from any component:
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { useTheme } from '@philiprehberger/react-theme-provider';
|
|
35
|
+
|
|
36
|
+
function MyComponent() {
|
|
37
|
+
const { theme, setTheme, resolvedTheme } = useTheme();
|
|
38
|
+
// theme: 'light' | 'dark' | 'system'
|
|
39
|
+
// resolvedTheme: 'light' | 'dark' (actual applied theme)
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### ThemeToggle
|
|
44
|
+
|
|
45
|
+
Pre-built toggle component with sun/moon/system icons:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { ThemeToggle } from '@philiprehberger/react-theme-provider';
|
|
49
|
+
|
|
50
|
+
<ThemeToggle />
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
- Applies `light` or `dark` class to `document.documentElement`
|
|
56
|
+
- Persists user preference to `localStorage`
|
|
57
|
+
- Listens for system `prefers-color-scheme` changes when set to `system`
|
|
58
|
+
- Works with Tailwind CSS `dark:` variant out of the box
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
|
|
6
|
+
var ThemeContext = react.createContext(void 0);
|
|
7
|
+
function ThemeProvider({
|
|
8
|
+
children,
|
|
9
|
+
storageKey = "theme",
|
|
10
|
+
defaultTheme = "system"
|
|
11
|
+
}) {
|
|
12
|
+
const [theme, setTheme] = react.useState(defaultTheme);
|
|
13
|
+
const [resolvedTheme, setResolvedTheme] = react.useState("light");
|
|
14
|
+
const [mounted, setMounted] = react.useState(false);
|
|
15
|
+
react.useEffect(() => {
|
|
16
|
+
setMounted(true);
|
|
17
|
+
const stored = localStorage.getItem(storageKey);
|
|
18
|
+
if (stored && ["light", "dark", "system"].includes(stored)) {
|
|
19
|
+
setTheme(stored);
|
|
20
|
+
}
|
|
21
|
+
}, [storageKey]);
|
|
22
|
+
react.useEffect(() => {
|
|
23
|
+
if (!mounted) return;
|
|
24
|
+
const root = document.documentElement;
|
|
25
|
+
let resolved;
|
|
26
|
+
if (theme === "system") {
|
|
27
|
+
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
28
|
+
resolved = systemDark ? "dark" : "light";
|
|
29
|
+
} else {
|
|
30
|
+
resolved = theme;
|
|
31
|
+
}
|
|
32
|
+
setResolvedTheme(resolved);
|
|
33
|
+
root.classList.remove("light", "dark");
|
|
34
|
+
root.classList.add(resolved);
|
|
35
|
+
localStorage.setItem(storageKey, theme);
|
|
36
|
+
}, [theme, mounted, storageKey]);
|
|
37
|
+
react.useEffect(() => {
|
|
38
|
+
if (!mounted || theme !== "system") return;
|
|
39
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
40
|
+
const handleChange = (e) => {
|
|
41
|
+
setResolvedTheme(e.matches ? "dark" : "light");
|
|
42
|
+
document.documentElement.classList.remove("light", "dark");
|
|
43
|
+
document.documentElement.classList.add(e.matches ? "dark" : "light");
|
|
44
|
+
};
|
|
45
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
46
|
+
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
47
|
+
}, [theme, mounted]);
|
|
48
|
+
return /* @__PURE__ */ jsxRuntime.jsx(ThemeContext.Provider, { value: { theme, setTheme, resolvedTheme }, children });
|
|
49
|
+
}
|
|
50
|
+
function useTheme() {
|
|
51
|
+
const context = react.useContext(ThemeContext);
|
|
52
|
+
if (!context) {
|
|
53
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
54
|
+
}
|
|
55
|
+
return context;
|
|
56
|
+
}
|
|
57
|
+
var SunIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" }) });
|
|
58
|
+
var MoonIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" }) });
|
|
59
|
+
var SystemIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" }) });
|
|
60
|
+
function ThemeToggle({
|
|
61
|
+
className = "flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800",
|
|
62
|
+
activeClassName = "rounded-md p-2 transition-colors bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white",
|
|
63
|
+
inactiveClassName = "rounded-md p-2 transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
|
64
|
+
}) {
|
|
65
|
+
const { theme, setTheme } = useTheme();
|
|
66
|
+
const options = [
|
|
67
|
+
{ value: "light", icon: SunIcon, label: "Light" },
|
|
68
|
+
{ value: "dark", icon: MoonIcon, label: "Dark" },
|
|
69
|
+
{ value: "system", icon: SystemIcon, label: "System" }
|
|
70
|
+
];
|
|
71
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className, role: "radiogroup", "aria-label": "Theme selection", children: options.map(({ value, icon: Icon, label }) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
72
|
+
"button",
|
|
73
|
+
{
|
|
74
|
+
onClick: () => setTheme(value),
|
|
75
|
+
role: "radio",
|
|
76
|
+
"aria-checked": theme === value,
|
|
77
|
+
"aria-label": `${label} theme`,
|
|
78
|
+
className: theme === value ? activeClassName : inactiveClassName,
|
|
79
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(Icon, {})
|
|
80
|
+
},
|
|
81
|
+
value
|
|
82
|
+
)) });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
exports.ThemeProvider = ThemeProvider;
|
|
86
|
+
exports.ThemeToggle = ThemeToggle;
|
|
87
|
+
exports.useTheme = useTheme;
|
|
88
|
+
//# sourceMappingURL=index.cjs.map
|
|
89
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ThemeProvider.tsx","../src/ThemeToggle.tsx"],"names":["createContext","useState","useEffect","jsx","useContext"],"mappings":"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","file":"index.cjs","sourcesContent":["import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';\n\nexport type Theme = 'light' | 'dark' | 'system';\nexport type ResolvedTheme = 'light' | 'dark';\n\nexport interface ThemeContextType {\n theme: Theme;\n setTheme: (theme: Theme) => void;\n resolvedTheme: ResolvedTheme;\n}\n\nconst ThemeContext = createContext<ThemeContextType | undefined>(undefined);\n\nexport interface ThemeProviderProps {\n children: ReactNode;\n /** localStorage key for persisting theme (default: \"theme\") */\n storageKey?: string;\n /** Default theme when no stored preference exists (default: \"system\") */\n defaultTheme?: Theme;\n}\n\n/**\n * Theme provider with dark/light/system support.\n * Persists preference to localStorage and syncs with system preference changes.\n * Applies theme by toggling \"light\"/\"dark\" classes on the document element.\n */\nexport function ThemeProvider({\n children,\n storageKey = 'theme',\n defaultTheme = 'system',\n}: ThemeProviderProps) {\n const [theme, setTheme] = useState<Theme>(defaultTheme);\n const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n setMounted(true);\n const stored = localStorage.getItem(storageKey) as Theme | null;\n if (stored && ['light', 'dark', 'system'].includes(stored)) {\n setTheme(stored);\n }\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n\n const root = document.documentElement;\n let resolved: ResolvedTheme;\n\n if (theme === 'system') {\n const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n resolved = systemDark ? 'dark' : 'light';\n } else {\n resolved = theme;\n }\n\n setResolvedTheme(resolved);\n root.classList.remove('light', 'dark');\n root.classList.add(resolved);\n localStorage.setItem(storageKey, theme);\n }, [theme, mounted, storageKey]);\n\n useEffect(() => {\n if (!mounted || theme !== 'system') return;\n\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n const handleChange = (e: MediaQueryListEvent) => {\n setResolvedTheme(e.matches ? 'dark' : 'light');\n document.documentElement.classList.remove('light', 'dark');\n document.documentElement.classList.add(e.matches ? 'dark' : 'light');\n };\n\n mediaQuery.addEventListener('change', handleChange);\n return () => mediaQuery.removeEventListener('change', handleChange);\n }, [theme, mounted]);\n\n return (\n <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>\n {children}\n </ThemeContext.Provider>\n );\n}\n\n/**\n * Access the current theme context.\n * Must be used within a ThemeProvider.\n */\nexport function useTheme(): ThemeContextType {\n const context = useContext(ThemeContext);\n if (!context) {\n throw new Error('useTheme must be used within a ThemeProvider');\n }\n return context;\n}\n","import { type ReactNode } from 'react';\nimport { useTheme, type Theme } from './ThemeProvider';\n\nconst SunIcon = () => (\n <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z\" />\n </svg>\n);\n\nconst MoonIcon = () => (\n <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z\" />\n </svg>\n);\n\nconst SystemIcon = () => (\n <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\" />\n </svg>\n);\n\ninterface ThemeOption {\n value: Theme;\n icon: () => ReactNode;\n label: string;\n}\n\nexport interface ThemeToggleProps {\n /** CSS class for the outer container */\n className?: string;\n /** CSS class for the active button */\n activeClassName?: string;\n /** CSS class for inactive buttons */\n inactiveClassName?: string;\n}\n\n/**\n * Three-way theme toggle (light / dark / system).\n * Uses radio group semantics for accessibility.\n */\nexport function ThemeToggle({\n className = 'flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800',\n activeClassName = 'rounded-md p-2 transition-colors bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white',\n inactiveClassName = 'rounded-md p-2 transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',\n}: ThemeToggleProps) {\n const { theme, setTheme } = useTheme();\n\n const options: ThemeOption[] = [\n { value: 'light', icon: SunIcon, label: 'Light' },\n { value: 'dark', icon: MoonIcon, label: 'Dark' },\n { value: 'system', icon: SystemIcon, label: 'System' },\n ];\n\n return (\n <div className={className} role=\"radiogroup\" aria-label=\"Theme selection\">\n {options.map(({ value, icon: Icon, label }) => (\n <button\n key={value}\n onClick={() => setTheme(value)}\n role=\"radio\"\n aria-checked={theme === value}\n aria-label={`${label} theme`}\n className={theme === value ? activeClassName : inactiveClassName}\n >\n <Icon />\n </button>\n ))}\n </div>\n );\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
type Theme = 'light' | 'dark' | 'system';
|
|
5
|
+
type ResolvedTheme = 'light' | 'dark';
|
|
6
|
+
interface ThemeContextType {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
setTheme: (theme: Theme) => void;
|
|
9
|
+
resolvedTheme: ResolvedTheme;
|
|
10
|
+
}
|
|
11
|
+
interface ThemeProviderProps {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
/** localStorage key for persisting theme (default: "theme") */
|
|
14
|
+
storageKey?: string;
|
|
15
|
+
/** Default theme when no stored preference exists (default: "system") */
|
|
16
|
+
defaultTheme?: Theme;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Theme provider with dark/light/system support.
|
|
20
|
+
* Persists preference to localStorage and syncs with system preference changes.
|
|
21
|
+
* Applies theme by toggling "light"/"dark" classes on the document element.
|
|
22
|
+
*/
|
|
23
|
+
declare function ThemeProvider({ children, storageKey, defaultTheme, }: ThemeProviderProps): react_jsx_runtime.JSX.Element;
|
|
24
|
+
/**
|
|
25
|
+
* Access the current theme context.
|
|
26
|
+
* Must be used within a ThemeProvider.
|
|
27
|
+
*/
|
|
28
|
+
declare function useTheme(): ThemeContextType;
|
|
29
|
+
|
|
30
|
+
interface ThemeToggleProps {
|
|
31
|
+
/** CSS class for the outer container */
|
|
32
|
+
className?: string;
|
|
33
|
+
/** CSS class for the active button */
|
|
34
|
+
activeClassName?: string;
|
|
35
|
+
/** CSS class for inactive buttons */
|
|
36
|
+
inactiveClassName?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Three-way theme toggle (light / dark / system).
|
|
40
|
+
* Uses radio group semantics for accessibility.
|
|
41
|
+
*/
|
|
42
|
+
declare function ThemeToggle({ className, activeClassName, inactiveClassName, }: ThemeToggleProps): react_jsx_runtime.JSX.Element;
|
|
43
|
+
|
|
44
|
+
export { type ResolvedTheme, type Theme, type ThemeContextType, ThemeProvider, type ThemeProviderProps, ThemeToggle, type ThemeToggleProps, useTheme };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
type Theme = 'light' | 'dark' | 'system';
|
|
5
|
+
type ResolvedTheme = 'light' | 'dark';
|
|
6
|
+
interface ThemeContextType {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
setTheme: (theme: Theme) => void;
|
|
9
|
+
resolvedTheme: ResolvedTheme;
|
|
10
|
+
}
|
|
11
|
+
interface ThemeProviderProps {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
/** localStorage key for persisting theme (default: "theme") */
|
|
14
|
+
storageKey?: string;
|
|
15
|
+
/** Default theme when no stored preference exists (default: "system") */
|
|
16
|
+
defaultTheme?: Theme;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Theme provider with dark/light/system support.
|
|
20
|
+
* Persists preference to localStorage and syncs with system preference changes.
|
|
21
|
+
* Applies theme by toggling "light"/"dark" classes on the document element.
|
|
22
|
+
*/
|
|
23
|
+
declare function ThemeProvider({ children, storageKey, defaultTheme, }: ThemeProviderProps): react_jsx_runtime.JSX.Element;
|
|
24
|
+
/**
|
|
25
|
+
* Access the current theme context.
|
|
26
|
+
* Must be used within a ThemeProvider.
|
|
27
|
+
*/
|
|
28
|
+
declare function useTheme(): ThemeContextType;
|
|
29
|
+
|
|
30
|
+
interface ThemeToggleProps {
|
|
31
|
+
/** CSS class for the outer container */
|
|
32
|
+
className?: string;
|
|
33
|
+
/** CSS class for the active button */
|
|
34
|
+
activeClassName?: string;
|
|
35
|
+
/** CSS class for inactive buttons */
|
|
36
|
+
inactiveClassName?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Three-way theme toggle (light / dark / system).
|
|
40
|
+
* Uses radio group semantics for accessibility.
|
|
41
|
+
*/
|
|
42
|
+
declare function ThemeToggle({ className, activeClassName, inactiveClassName, }: ThemeToggleProps): react_jsx_runtime.JSX.Element;
|
|
43
|
+
|
|
44
|
+
export { type ResolvedTheme, type Theme, type ThemeContextType, ThemeProvider, type ThemeProviderProps, ThemeToggle, type ThemeToggleProps, useTheme };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createContext, useState, useEffect, useContext } from 'react';
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
var ThemeContext = createContext(void 0);
|
|
5
|
+
function ThemeProvider({
|
|
6
|
+
children,
|
|
7
|
+
storageKey = "theme",
|
|
8
|
+
defaultTheme = "system"
|
|
9
|
+
}) {
|
|
10
|
+
const [theme, setTheme] = useState(defaultTheme);
|
|
11
|
+
const [resolvedTheme, setResolvedTheme] = useState("light");
|
|
12
|
+
const [mounted, setMounted] = useState(false);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setMounted(true);
|
|
15
|
+
const stored = localStorage.getItem(storageKey);
|
|
16
|
+
if (stored && ["light", "dark", "system"].includes(stored)) {
|
|
17
|
+
setTheme(stored);
|
|
18
|
+
}
|
|
19
|
+
}, [storageKey]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!mounted) return;
|
|
22
|
+
const root = document.documentElement;
|
|
23
|
+
let resolved;
|
|
24
|
+
if (theme === "system") {
|
|
25
|
+
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
26
|
+
resolved = systemDark ? "dark" : "light";
|
|
27
|
+
} else {
|
|
28
|
+
resolved = theme;
|
|
29
|
+
}
|
|
30
|
+
setResolvedTheme(resolved);
|
|
31
|
+
root.classList.remove("light", "dark");
|
|
32
|
+
root.classList.add(resolved);
|
|
33
|
+
localStorage.setItem(storageKey, theme);
|
|
34
|
+
}, [theme, mounted, storageKey]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!mounted || theme !== "system") return;
|
|
37
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
38
|
+
const handleChange = (e) => {
|
|
39
|
+
setResolvedTheme(e.matches ? "dark" : "light");
|
|
40
|
+
document.documentElement.classList.remove("light", "dark");
|
|
41
|
+
document.documentElement.classList.add(e.matches ? "dark" : "light");
|
|
42
|
+
};
|
|
43
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
44
|
+
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
45
|
+
}, [theme, mounted]);
|
|
46
|
+
return /* @__PURE__ */ jsx(ThemeContext.Provider, { value: { theme, setTheme, resolvedTheme }, children });
|
|
47
|
+
}
|
|
48
|
+
function useTheme() {
|
|
49
|
+
const context = useContext(ThemeContext);
|
|
50
|
+
if (!context) {
|
|
51
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
52
|
+
}
|
|
53
|
+
return context;
|
|
54
|
+
}
|
|
55
|
+
var SunIcon = () => /* @__PURE__ */ jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" }) });
|
|
56
|
+
var MoonIcon = () => /* @__PURE__ */ jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" }) });
|
|
57
|
+
var SystemIcon = () => /* @__PURE__ */ jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" }) });
|
|
58
|
+
function ThemeToggle({
|
|
59
|
+
className = "flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800",
|
|
60
|
+
activeClassName = "rounded-md p-2 transition-colors bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white",
|
|
61
|
+
inactiveClassName = "rounded-md p-2 transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
|
62
|
+
}) {
|
|
63
|
+
const { theme, setTheme } = useTheme();
|
|
64
|
+
const options = [
|
|
65
|
+
{ value: "light", icon: SunIcon, label: "Light" },
|
|
66
|
+
{ value: "dark", icon: MoonIcon, label: "Dark" },
|
|
67
|
+
{ value: "system", icon: SystemIcon, label: "System" }
|
|
68
|
+
];
|
|
69
|
+
return /* @__PURE__ */ jsx("div", { className, role: "radiogroup", "aria-label": "Theme selection", children: options.map(({ value, icon: Icon, label }) => /* @__PURE__ */ jsx(
|
|
70
|
+
"button",
|
|
71
|
+
{
|
|
72
|
+
onClick: () => setTheme(value),
|
|
73
|
+
role: "radio",
|
|
74
|
+
"aria-checked": theme === value,
|
|
75
|
+
"aria-label": `${label} theme`,
|
|
76
|
+
className: theme === value ? activeClassName : inactiveClassName,
|
|
77
|
+
children: /* @__PURE__ */ jsx(Icon, {})
|
|
78
|
+
},
|
|
79
|
+
value
|
|
80
|
+
)) });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { ThemeProvider, ThemeToggle, useTheme };
|
|
84
|
+
//# sourceMappingURL=index.js.map
|
|
85
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ThemeProvider.tsx","../src/ThemeToggle.tsx"],"names":["jsx"],"mappings":"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","file":"index.js","sourcesContent":["import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';\n\nexport type Theme = 'light' | 'dark' | 'system';\nexport type ResolvedTheme = 'light' | 'dark';\n\nexport interface ThemeContextType {\n theme: Theme;\n setTheme: (theme: Theme) => void;\n resolvedTheme: ResolvedTheme;\n}\n\nconst ThemeContext = createContext<ThemeContextType | undefined>(undefined);\n\nexport interface ThemeProviderProps {\n children: ReactNode;\n /** localStorage key for persisting theme (default: \"theme\") */\n storageKey?: string;\n /** Default theme when no stored preference exists (default: \"system\") */\n defaultTheme?: Theme;\n}\n\n/**\n * Theme provider with dark/light/system support.\n * Persists preference to localStorage and syncs with system preference changes.\n * Applies theme by toggling \"light\"/\"dark\" classes on the document element.\n */\nexport function ThemeProvider({\n children,\n storageKey = 'theme',\n defaultTheme = 'system',\n}: ThemeProviderProps) {\n const [theme, setTheme] = useState<Theme>(defaultTheme);\n const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n setMounted(true);\n const stored = localStorage.getItem(storageKey) as Theme | null;\n if (stored && ['light', 'dark', 'system'].includes(stored)) {\n setTheme(stored);\n }\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n\n const root = document.documentElement;\n let resolved: ResolvedTheme;\n\n if (theme === 'system') {\n const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n resolved = systemDark ? 'dark' : 'light';\n } else {\n resolved = theme;\n }\n\n setResolvedTheme(resolved);\n root.classList.remove('light', 'dark');\n root.classList.add(resolved);\n localStorage.setItem(storageKey, theme);\n }, [theme, mounted, storageKey]);\n\n useEffect(() => {\n if (!mounted || theme !== 'system') return;\n\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n const handleChange = (e: MediaQueryListEvent) => {\n setResolvedTheme(e.matches ? 'dark' : 'light');\n document.documentElement.classList.remove('light', 'dark');\n document.documentElement.classList.add(e.matches ? 'dark' : 'light');\n };\n\n mediaQuery.addEventListener('change', handleChange);\n return () => mediaQuery.removeEventListener('change', handleChange);\n }, [theme, mounted]);\n\n return (\n <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>\n {children}\n </ThemeContext.Provider>\n );\n}\n\n/**\n * Access the current theme context.\n * Must be used within a ThemeProvider.\n */\nexport function useTheme(): ThemeContextType {\n const context = useContext(ThemeContext);\n if (!context) {\n throw new Error('useTheme must be used within a ThemeProvider');\n }\n return context;\n}\n","import { type ReactNode } from 'react';\nimport { useTheme, type Theme } from './ThemeProvider';\n\nconst SunIcon = () => (\n <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z\" />\n </svg>\n);\n\nconst MoonIcon = () => (\n <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z\" />\n </svg>\n);\n\nconst SystemIcon = () => (\n <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\" />\n </svg>\n);\n\ninterface ThemeOption {\n value: Theme;\n icon: () => ReactNode;\n label: string;\n}\n\nexport interface ThemeToggleProps {\n /** CSS class for the outer container */\n className?: string;\n /** CSS class for the active button */\n activeClassName?: string;\n /** CSS class for inactive buttons */\n inactiveClassName?: string;\n}\n\n/**\n * Three-way theme toggle (light / dark / system).\n * Uses radio group semantics for accessibility.\n */\nexport function ThemeToggle({\n className = 'flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800',\n activeClassName = 'rounded-md p-2 transition-colors bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white',\n inactiveClassName = 'rounded-md p-2 transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',\n}: ThemeToggleProps) {\n const { theme, setTheme } = useTheme();\n\n const options: ThemeOption[] = [\n { value: 'light', icon: SunIcon, label: 'Light' },\n { value: 'dark', icon: MoonIcon, label: 'Dark' },\n { value: 'system', icon: SystemIcon, label: 'System' },\n ];\n\n return (\n <div className={className} role=\"radiogroup\" aria-label=\"Theme selection\">\n {options.map(({ value, icon: Icon, label }) => (\n <button\n key={value}\n onClick={() => setTheme(value)}\n role=\"radio\"\n aria-checked={theme === value}\n aria-label={`${label} theme`}\n className={theme === value ? activeClassName : inactiveClassName}\n >\n <Icon />\n </button>\n ))}\n </div>\n );\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@philiprehberger/react-theme-provider",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dark/light/system theme provider for React with localStorage persistence and system preference detection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=18.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^19.0.0",
|
|
35
|
+
"react": "^19.0.0",
|
|
36
|
+
"tsup": "^8.0.0",
|
|
37
|
+
"typescript": "^5.0.0"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"react",
|
|
41
|
+
"theme",
|
|
42
|
+
"dark-mode",
|
|
43
|
+
"light-mode",
|
|
44
|
+
"theme-provider",
|
|
45
|
+
"system-preference"
|
|
46
|
+
],
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "https://github.com/philiprehberger/react-theme-provider"
|
|
51
|
+
},
|
|
52
|
+
"sideEffects": false
|
|
53
|
+
}
|