@justin_evo/evo-ui 1.1.0 → 1.2.1
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 +3 -3
- package/dist/TopNav/TopNav.d.ts +19 -0
- package/dist/declarations.d.ts +6 -6
- package/dist/evo-ui.css +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +3301 -3197
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -869
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -994
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -120
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -396
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- package/src/index.ts +60 -60
|
@@ -1,253 +1,253 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
type ReactNode,
|
|
10
|
-
} from 'react';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The three theme modes EvoUI supports.
|
|
14
|
-
* - `'light'` / `'dark'` — force the theme regardless of OS preference.
|
|
15
|
-
* - `'system'` — follow the user's OS-level color-scheme preference.
|
|
16
|
-
*/
|
|
17
|
-
export type EvoTheme = 'light' | 'dark' | 'system';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The actually-applied theme after resolving `'system'` against
|
|
21
|
-
* `window.matchMedia('(prefers-color-scheme: dark)')`. Always either
|
|
22
|
-
* `'light'` or `'dark'` — never `'system'`.
|
|
23
|
-
*/
|
|
24
|
-
export type EvoResolvedTheme = 'light' | 'dark';
|
|
25
|
-
|
|
26
|
-
export interface EvoThemeContextValue {
|
|
27
|
-
/** The user-selected mode (may be `'system'`). */
|
|
28
|
-
theme: EvoTheme;
|
|
29
|
-
/** The mode that is actually painted right now (`'light'` or `'dark'`). */
|
|
30
|
-
resolvedTheme: EvoResolvedTheme;
|
|
31
|
-
/** Switch to a specific mode. */
|
|
32
|
-
setTheme: (theme: EvoTheme) => void;
|
|
33
|
-
/** Convenience: flip between light and dark (treats `'system'` as its resolved value). */
|
|
34
|
-
toggleTheme: () => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const ThemeContext = createContext<EvoThemeContextValue | null>(null);
|
|
38
|
-
|
|
39
|
-
export interface EvoThemeProviderProps {
|
|
40
|
-
/** Subtree to provide the theme to. */
|
|
41
|
-
children: ReactNode;
|
|
42
|
-
/**
|
|
43
|
-
* Initial theme used before any persisted value is read.
|
|
44
|
-
* @default 'system'
|
|
45
|
-
*/
|
|
46
|
-
defaultTheme?: EvoTheme;
|
|
47
|
-
/**
|
|
48
|
-
* localStorage key used to persist the user's choice across reloads.
|
|
49
|
-
* Pass `null` to disable persistence entirely.
|
|
50
|
-
* @default 'evo-ui-theme'
|
|
51
|
-
*/
|
|
52
|
-
storageKey?: string | null;
|
|
53
|
-
/**
|
|
54
|
-
* HTML attribute written to the document root. Most apps want
|
|
55
|
-
* `'data-theme'`; pass `'class'` to instead toggle `light` / `dark`
|
|
56
|
-
* as className (useful if you're sharing tokens with Tailwind).
|
|
57
|
-
* @default 'data-theme'
|
|
58
|
-
*/
|
|
59
|
-
attribute?: 'data-theme' | 'class';
|
|
60
|
-
/**
|
|
61
|
-
* Animate color transitions when the theme flips.
|
|
62
|
-
* Automatically disabled for users with `prefers-reduced-motion`.
|
|
63
|
-
* @default true
|
|
64
|
-
*/
|
|
65
|
-
enableTransitions?: boolean;
|
|
66
|
-
/**
|
|
67
|
-
* Element to apply the theme attribute to.
|
|
68
|
-
* @default document.documentElement
|
|
69
|
-
*/
|
|
70
|
-
target?: HTMLElement;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const STORAGE_KEY_DEFAULT = 'evo-ui-theme';
|
|
74
|
-
|
|
75
|
-
function getSystemTheme(): EvoResolvedTheme {
|
|
76
|
-
if (typeof window === 'undefined' || !window.matchMedia) return 'light';
|
|
77
|
-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function readStoredTheme(key: string | null): EvoTheme | null {
|
|
81
|
-
if (!key || typeof window === 'undefined') return null;
|
|
82
|
-
try {
|
|
83
|
-
const value = window.localStorage.getItem(key);
|
|
84
|
-
if (value === 'light' || value === 'dark' || value === 'system') return value;
|
|
85
|
-
} catch {
|
|
86
|
-
// localStorage can throw in private mode / sandboxed iframes.
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Provides EvoUI theming context to descendant components.
|
|
93
|
-
*
|
|
94
|
-
* @example
|
|
95
|
-
* ```tsx
|
|
96
|
-
* import { EvoThemeProvider } from '@justin_evo/evo-ui';
|
|
97
|
-
*
|
|
98
|
-
* <EvoThemeProvider defaultTheme="system">
|
|
99
|
-
* <App />
|
|
100
|
-
* </EvoThemeProvider>
|
|
101
|
-
* ```
|
|
102
|
-
*/
|
|
103
|
-
export const EvoThemeProvider = ({
|
|
104
|
-
children,
|
|
105
|
-
defaultTheme = 'system',
|
|
106
|
-
storageKey = STORAGE_KEY_DEFAULT,
|
|
107
|
-
attribute = 'data-theme',
|
|
108
|
-
enableTransitions = true,
|
|
109
|
-
target,
|
|
110
|
-
}: EvoThemeProviderProps) => {
|
|
111
|
-
const [theme, setThemeState] = useState<EvoTheme>(() => {
|
|
112
|
-
return readStoredTheme(storageKey) ?? defaultTheme;
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const [resolvedTheme, setResolvedTheme] = useState<EvoResolvedTheme>(() => {
|
|
116
|
-
const initial = readStoredTheme(storageKey) ?? defaultTheme;
|
|
117
|
-
return initial === 'system' ? getSystemTheme() : initial;
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const isFirstApply = useRef(true);
|
|
121
|
-
|
|
122
|
-
const applyToDOM = useCallback(
|
|
123
|
-
(resolved: EvoResolvedTheme) => {
|
|
124
|
-
if (typeof document === 'undefined') return;
|
|
125
|
-
const el = target ?? document.documentElement;
|
|
126
|
-
|
|
127
|
-
// Enable transitions only AFTER the first paint, so the page
|
|
128
|
-
// doesn't fade in from the wrong colors on initial load.
|
|
129
|
-
if (enableTransitions && !isFirstApply.current) {
|
|
130
|
-
el.setAttribute('data-theme-transition', 'true');
|
|
131
|
-
window.clearTimeout((el as any).__evoThemeTimer);
|
|
132
|
-
(el as any).__evoThemeTimer = window.setTimeout(() => {
|
|
133
|
-
el.removeAttribute('data-theme-transition');
|
|
134
|
-
}, 250);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (attribute === 'class') {
|
|
138
|
-
el.classList.remove('light', 'dark');
|
|
139
|
-
el.classList.add(resolved);
|
|
140
|
-
// Always set data-theme too so our CSS variables resolve.
|
|
141
|
-
el.setAttribute('data-theme', resolved);
|
|
142
|
-
} else {
|
|
143
|
-
el.setAttribute('data-theme', resolved);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
isFirstApply.current = false;
|
|
147
|
-
},
|
|
148
|
-
[attribute, enableTransitions, target],
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
// Apply theme to DOM whenever resolvedTheme changes.
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
applyToDOM(resolvedTheme);
|
|
154
|
-
}, [resolvedTheme, applyToDOM]);
|
|
155
|
-
|
|
156
|
-
// Recompute resolvedTheme when theme changes.
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
setResolvedTheme(theme === 'system' ? getSystemTheme() : theme);
|
|
159
|
-
}, [theme]);
|
|
160
|
-
|
|
161
|
-
// When the user is on 'system', listen for OS-level changes.
|
|
162
|
-
useEffect(() => {
|
|
163
|
-
if (theme !== 'system' || typeof window === 'undefined' || !window.matchMedia) return;
|
|
164
|
-
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
|
165
|
-
const handler = (e: MediaQueryListEvent) => {
|
|
166
|
-
setResolvedTheme(e.matches ? 'dark' : 'light');
|
|
167
|
-
};
|
|
168
|
-
mql.addEventListener('change', handler);
|
|
169
|
-
return () => mql.removeEventListener('change', handler);
|
|
170
|
-
}, [theme]);
|
|
171
|
-
|
|
172
|
-
const setTheme = useCallback(
|
|
173
|
-
(next: EvoTheme) => {
|
|
174
|
-
setThemeState(next);
|
|
175
|
-
if (storageKey && typeof window !== 'undefined') {
|
|
176
|
-
try {
|
|
177
|
-
window.localStorage.setItem(storageKey, next);
|
|
178
|
-
} catch {
|
|
179
|
-
// Storage might be unavailable — fail silently.
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
},
|
|
183
|
-
[storageKey],
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const toggleTheme = useCallback(() => {
|
|
187
|
-
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
188
|
-
}, [resolvedTheme, setTheme]);
|
|
189
|
-
|
|
190
|
-
const value = useMemo<EvoThemeContextValue>(
|
|
191
|
-
() => ({ theme, resolvedTheme, setTheme, toggleTheme }),
|
|
192
|
-
[theme, resolvedTheme, setTheme, toggleTheme],
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Read and update the EvoUI theme.
|
|
200
|
-
*
|
|
201
|
-
* Must be called from a descendant of `<EvoThemeProvider>`. If used
|
|
202
|
-
* outside the provider, returns a no-op object with `resolvedTheme`
|
|
203
|
-
* set to whatever is currently on `document.documentElement`.
|
|
204
|
-
*
|
|
205
|
-
* @example
|
|
206
|
-
* ```tsx
|
|
207
|
-
* const { resolvedTheme, setTheme, toggleTheme } = useEvoTheme();
|
|
208
|
-
* <button onClick={toggleTheme}>
|
|
209
|
-
* {resolvedTheme === 'dark' ? '☀️' : '🌙'}
|
|
210
|
-
* </button>
|
|
211
|
-
* ```
|
|
212
|
-
*/
|
|
213
|
-
export const useEvoTheme = (): EvoThemeContextValue => {
|
|
214
|
-
const ctx = useContext(ThemeContext);
|
|
215
|
-
if (ctx) return ctx;
|
|
216
|
-
|
|
217
|
-
// Graceful fallback if hook is called without a provider —
|
|
218
|
-
// common in standalone widgets where the host app sets theme manually.
|
|
219
|
-
const fallbackResolved: EvoResolvedTheme =
|
|
220
|
-
typeof document !== 'undefined' &&
|
|
221
|
-
document.documentElement.getAttribute('data-theme') === 'dark'
|
|
222
|
-
? 'dark'
|
|
223
|
-
: 'light';
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
theme: fallbackResolved,
|
|
227
|
-
resolvedTheme: fallbackResolved,
|
|
228
|
-
setTheme: () => {
|
|
229
|
-
if (typeof document !== 'undefined') {
|
|
230
|
-
// eslint-disable-next-line no-console
|
|
231
|
-
console.warn(
|
|
232
|
-
'[evo-ui] useEvoTheme called without <EvoThemeProvider>. ' +
|
|
233
|
-
'Wrap your app in <EvoThemeProvider> to enable setTheme().',
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
},
|
|
237
|
-
toggleTheme: () => {},
|
|
238
|
-
};
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Inline script that applies the persisted theme before React hydrates,
|
|
243
|
-
* preventing the white-flash on first paint in dark mode. Drop into your
|
|
244
|
-
* `<head>` (or Next.js `<Script strategy="beforeInteractive">`):
|
|
245
|
-
*
|
|
246
|
-
* @example
|
|
247
|
-
* ```html
|
|
248
|
-
* <script dangerouslySetInnerHTML={{ __html: getEvoThemeScript() }} />
|
|
249
|
-
* ```
|
|
250
|
-
*/
|
|
251
|
-
export const getEvoThemeScript = (storageKey: string = STORAGE_KEY_DEFAULT): string => {
|
|
252
|
-
return `(function(){try{var s=localStorage.getItem('${storageKey}');var t=s||'system';var r=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t;document.documentElement.setAttribute('data-theme',r);}catch(e){}})();`;
|
|
253
|
-
};
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The three theme modes EvoUI supports.
|
|
14
|
+
* - `'light'` / `'dark'` — force the theme regardless of OS preference.
|
|
15
|
+
* - `'system'` — follow the user's OS-level color-scheme preference.
|
|
16
|
+
*/
|
|
17
|
+
export type EvoTheme = 'light' | 'dark' | 'system';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The actually-applied theme after resolving `'system'` against
|
|
21
|
+
* `window.matchMedia('(prefers-color-scheme: dark)')`. Always either
|
|
22
|
+
* `'light'` or `'dark'` — never `'system'`.
|
|
23
|
+
*/
|
|
24
|
+
export type EvoResolvedTheme = 'light' | 'dark';
|
|
25
|
+
|
|
26
|
+
export interface EvoThemeContextValue {
|
|
27
|
+
/** The user-selected mode (may be `'system'`). */
|
|
28
|
+
theme: EvoTheme;
|
|
29
|
+
/** The mode that is actually painted right now (`'light'` or `'dark'`). */
|
|
30
|
+
resolvedTheme: EvoResolvedTheme;
|
|
31
|
+
/** Switch to a specific mode. */
|
|
32
|
+
setTheme: (theme: EvoTheme) => void;
|
|
33
|
+
/** Convenience: flip between light and dark (treats `'system'` as its resolved value). */
|
|
34
|
+
toggleTheme: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ThemeContext = createContext<EvoThemeContextValue | null>(null);
|
|
38
|
+
|
|
39
|
+
export interface EvoThemeProviderProps {
|
|
40
|
+
/** Subtree to provide the theme to. */
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
/**
|
|
43
|
+
* Initial theme used before any persisted value is read.
|
|
44
|
+
* @default 'system'
|
|
45
|
+
*/
|
|
46
|
+
defaultTheme?: EvoTheme;
|
|
47
|
+
/**
|
|
48
|
+
* localStorage key used to persist the user's choice across reloads.
|
|
49
|
+
* Pass `null` to disable persistence entirely.
|
|
50
|
+
* @default 'evo-ui-theme'
|
|
51
|
+
*/
|
|
52
|
+
storageKey?: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* HTML attribute written to the document root. Most apps want
|
|
55
|
+
* `'data-theme'`; pass `'class'` to instead toggle `light` / `dark`
|
|
56
|
+
* as className (useful if you're sharing tokens with Tailwind).
|
|
57
|
+
* @default 'data-theme'
|
|
58
|
+
*/
|
|
59
|
+
attribute?: 'data-theme' | 'class';
|
|
60
|
+
/**
|
|
61
|
+
* Animate color transitions when the theme flips.
|
|
62
|
+
* Automatically disabled for users with `prefers-reduced-motion`.
|
|
63
|
+
* @default true
|
|
64
|
+
*/
|
|
65
|
+
enableTransitions?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Element to apply the theme attribute to.
|
|
68
|
+
* @default document.documentElement
|
|
69
|
+
*/
|
|
70
|
+
target?: HTMLElement;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const STORAGE_KEY_DEFAULT = 'evo-ui-theme';
|
|
74
|
+
|
|
75
|
+
function getSystemTheme(): EvoResolvedTheme {
|
|
76
|
+
if (typeof window === 'undefined' || !window.matchMedia) return 'light';
|
|
77
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readStoredTheme(key: string | null): EvoTheme | null {
|
|
81
|
+
if (!key || typeof window === 'undefined') return null;
|
|
82
|
+
try {
|
|
83
|
+
const value = window.localStorage.getItem(key);
|
|
84
|
+
if (value === 'light' || value === 'dark' || value === 'system') return value;
|
|
85
|
+
} catch {
|
|
86
|
+
// localStorage can throw in private mode / sandboxed iframes.
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Provides EvoUI theming context to descendant components.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```tsx
|
|
96
|
+
* import { EvoThemeProvider } from '@justin_evo/evo-ui';
|
|
97
|
+
*
|
|
98
|
+
* <EvoThemeProvider defaultTheme="system">
|
|
99
|
+
* <App />
|
|
100
|
+
* </EvoThemeProvider>
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export const EvoThemeProvider = ({
|
|
104
|
+
children,
|
|
105
|
+
defaultTheme = 'system',
|
|
106
|
+
storageKey = STORAGE_KEY_DEFAULT,
|
|
107
|
+
attribute = 'data-theme',
|
|
108
|
+
enableTransitions = true,
|
|
109
|
+
target,
|
|
110
|
+
}: EvoThemeProviderProps) => {
|
|
111
|
+
const [theme, setThemeState] = useState<EvoTheme>(() => {
|
|
112
|
+
return readStoredTheme(storageKey) ?? defaultTheme;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const [resolvedTheme, setResolvedTheme] = useState<EvoResolvedTheme>(() => {
|
|
116
|
+
const initial = readStoredTheme(storageKey) ?? defaultTheme;
|
|
117
|
+
return initial === 'system' ? getSystemTheme() : initial;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const isFirstApply = useRef(true);
|
|
121
|
+
|
|
122
|
+
const applyToDOM = useCallback(
|
|
123
|
+
(resolved: EvoResolvedTheme) => {
|
|
124
|
+
if (typeof document === 'undefined') return;
|
|
125
|
+
const el = target ?? document.documentElement;
|
|
126
|
+
|
|
127
|
+
// Enable transitions only AFTER the first paint, so the page
|
|
128
|
+
// doesn't fade in from the wrong colors on initial load.
|
|
129
|
+
if (enableTransitions && !isFirstApply.current) {
|
|
130
|
+
el.setAttribute('data-theme-transition', 'true');
|
|
131
|
+
window.clearTimeout((el as any).__evoThemeTimer);
|
|
132
|
+
(el as any).__evoThemeTimer = window.setTimeout(() => {
|
|
133
|
+
el.removeAttribute('data-theme-transition');
|
|
134
|
+
}, 250);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (attribute === 'class') {
|
|
138
|
+
el.classList.remove('light', 'dark');
|
|
139
|
+
el.classList.add(resolved);
|
|
140
|
+
// Always set data-theme too so our CSS variables resolve.
|
|
141
|
+
el.setAttribute('data-theme', resolved);
|
|
142
|
+
} else {
|
|
143
|
+
el.setAttribute('data-theme', resolved);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
isFirstApply.current = false;
|
|
147
|
+
},
|
|
148
|
+
[attribute, enableTransitions, target],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Apply theme to DOM whenever resolvedTheme changes.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
applyToDOM(resolvedTheme);
|
|
154
|
+
}, [resolvedTheme, applyToDOM]);
|
|
155
|
+
|
|
156
|
+
// Recompute resolvedTheme when theme changes.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
setResolvedTheme(theme === 'system' ? getSystemTheme() : theme);
|
|
159
|
+
}, [theme]);
|
|
160
|
+
|
|
161
|
+
// When the user is on 'system', listen for OS-level changes.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (theme !== 'system' || typeof window === 'undefined' || !window.matchMedia) return;
|
|
164
|
+
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
|
165
|
+
const handler = (e: MediaQueryListEvent) => {
|
|
166
|
+
setResolvedTheme(e.matches ? 'dark' : 'light');
|
|
167
|
+
};
|
|
168
|
+
mql.addEventListener('change', handler);
|
|
169
|
+
return () => mql.removeEventListener('change', handler);
|
|
170
|
+
}, [theme]);
|
|
171
|
+
|
|
172
|
+
const setTheme = useCallback(
|
|
173
|
+
(next: EvoTheme) => {
|
|
174
|
+
setThemeState(next);
|
|
175
|
+
if (storageKey && typeof window !== 'undefined') {
|
|
176
|
+
try {
|
|
177
|
+
window.localStorage.setItem(storageKey, next);
|
|
178
|
+
} catch {
|
|
179
|
+
// Storage might be unavailable — fail silently.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[storageKey],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const toggleTheme = useCallback(() => {
|
|
187
|
+
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
188
|
+
}, [resolvedTheme, setTheme]);
|
|
189
|
+
|
|
190
|
+
const value = useMemo<EvoThemeContextValue>(
|
|
191
|
+
() => ({ theme, resolvedTheme, setTheme, toggleTheme }),
|
|
192
|
+
[theme, resolvedTheme, setTheme, toggleTheme],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read and update the EvoUI theme.
|
|
200
|
+
*
|
|
201
|
+
* Must be called from a descendant of `<EvoThemeProvider>`. If used
|
|
202
|
+
* outside the provider, returns a no-op object with `resolvedTheme`
|
|
203
|
+
* set to whatever is currently on `document.documentElement`.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```tsx
|
|
207
|
+
* const { resolvedTheme, setTheme, toggleTheme } = useEvoTheme();
|
|
208
|
+
* <button onClick={toggleTheme}>
|
|
209
|
+
* {resolvedTheme === 'dark' ? '☀️' : '🌙'}
|
|
210
|
+
* </button>
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export const useEvoTheme = (): EvoThemeContextValue => {
|
|
214
|
+
const ctx = useContext(ThemeContext);
|
|
215
|
+
if (ctx) return ctx;
|
|
216
|
+
|
|
217
|
+
// Graceful fallback if hook is called without a provider —
|
|
218
|
+
// common in standalone widgets where the host app sets theme manually.
|
|
219
|
+
const fallbackResolved: EvoResolvedTheme =
|
|
220
|
+
typeof document !== 'undefined' &&
|
|
221
|
+
document.documentElement.getAttribute('data-theme') === 'dark'
|
|
222
|
+
? 'dark'
|
|
223
|
+
: 'light';
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
theme: fallbackResolved,
|
|
227
|
+
resolvedTheme: fallbackResolved,
|
|
228
|
+
setTheme: () => {
|
|
229
|
+
if (typeof document !== 'undefined') {
|
|
230
|
+
// eslint-disable-next-line no-console
|
|
231
|
+
console.warn(
|
|
232
|
+
'[evo-ui] useEvoTheme called without <EvoThemeProvider>. ' +
|
|
233
|
+
'Wrap your app in <EvoThemeProvider> to enable setTheme().',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
toggleTheme: () => {},
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Inline script that applies the persisted theme before React hydrates,
|
|
243
|
+
* preventing the white-flash on first paint in dark mode. Drop into your
|
|
244
|
+
* `<head>` (or Next.js `<Script strategy="beforeInteractive">`):
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* ```html
|
|
248
|
+
* <script dangerouslySetInnerHTML={{ __html: getEvoThemeScript() }} />
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
export const getEvoThemeScript = (storageKey: string = STORAGE_KEY_DEFAULT): string => {
|
|
252
|
+
return `(function(){try{var s=localStorage.getItem('${storageKey}');var t=s||'system';var r=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t;document.documentElement.setAttribute('data-theme',r);}catch(e){}})();`;
|
|
253
|
+
};
|
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
import { useEvoTheme } from './ThemeProvider';
|
|
2
|
-
import styles from '../css/theme-toggle.module.scss';
|
|
3
|
-
|
|
4
|
-
export interface EvoThemeToggleProps {
|
|
5
|
-
/** Visual size of the toggle. @default 'md' */
|
|
6
|
-
size?: 'sm' | 'md' | 'lg';
|
|
7
|
-
/** Accessible label. @default 'Toggle color theme' */
|
|
8
|
-
ariaLabel?: string;
|
|
9
|
-
/** Extra className to merge with the built-in styles. */
|
|
10
|
-
className?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* A drop-in button that flips between light and dark mode.
|
|
15
|
-
*
|
|
16
|
-
* Sits inside an `<EvoThemeProvider>`. The icon and animation
|
|
17
|
-
* automatically reflect the resolved theme.
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```tsx
|
|
21
|
-
* <EvoThemeProvider>
|
|
22
|
-
* <EvoThemeToggle />
|
|
23
|
-
* </EvoThemeProvider>
|
|
24
|
-
* ```
|
|
25
|
-
*/
|
|
26
|
-
export const EvoThemeToggle = ({
|
|
27
|
-
size = 'md',
|
|
28
|
-
ariaLabel = 'Toggle color theme',
|
|
29
|
-
className,
|
|
30
|
-
}: EvoThemeToggleProps) => {
|
|
31
|
-
const { resolvedTheme, toggleTheme } = useEvoTheme();
|
|
32
|
-
const isDark = resolvedTheme === 'dark';
|
|
33
|
-
|
|
34
|
-
const classes = [styles.toggle, styles[size], className].filter(Boolean).join(' ');
|
|
35
|
-
|
|
36
|
-
return (
|
|
37
|
-
<button
|
|
38
|
-
type="button"
|
|
39
|
-
role="switch"
|
|
40
|
-
aria-checked={isDark}
|
|
41
|
-
aria-label={ariaLabel}
|
|
42
|
-
onClick={toggleTheme}
|
|
43
|
-
className={classes}
|
|
44
|
-
data-theme-state={resolvedTheme}
|
|
45
|
-
>
|
|
46
|
-
<span className={styles.track}>
|
|
47
|
-
<span className={styles.thumb}>
|
|
48
|
-
{/* Sun icon */}
|
|
49
|
-
<svg
|
|
50
|
-
className={styles.sun}
|
|
51
|
-
viewBox="0 0 24 24"
|
|
52
|
-
fill="none"
|
|
53
|
-
stroke="currentColor"
|
|
54
|
-
strokeWidth="2"
|
|
55
|
-
strokeLinecap="round"
|
|
56
|
-
strokeLinejoin="round"
|
|
57
|
-
aria-hidden="true"
|
|
58
|
-
>
|
|
59
|
-
<circle cx="12" cy="12" r="4" />
|
|
60
|
-
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
|
61
|
-
</svg>
|
|
62
|
-
{/* Moon icon */}
|
|
63
|
-
<svg
|
|
64
|
-
className={styles.moon}
|
|
65
|
-
viewBox="0 0 24 24"
|
|
66
|
-
fill="none"
|
|
67
|
-
stroke="currentColor"
|
|
68
|
-
strokeWidth="2"
|
|
69
|
-
strokeLinecap="round"
|
|
70
|
-
strokeLinejoin="round"
|
|
71
|
-
aria-hidden="true"
|
|
72
|
-
>
|
|
73
|
-
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
74
|
-
</svg>
|
|
75
|
-
</span>
|
|
76
|
-
</span>
|
|
77
|
-
</button>
|
|
78
|
-
);
|
|
79
|
-
};
|
|
1
|
+
import { useEvoTheme } from './ThemeProvider';
|
|
2
|
+
import styles from '../css/theme-toggle.module.scss';
|
|
3
|
+
|
|
4
|
+
export interface EvoThemeToggleProps {
|
|
5
|
+
/** Visual size of the toggle. @default 'md' */
|
|
6
|
+
size?: 'sm' | 'md' | 'lg';
|
|
7
|
+
/** Accessible label. @default 'Toggle color theme' */
|
|
8
|
+
ariaLabel?: string;
|
|
9
|
+
/** Extra className to merge with the built-in styles. */
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A drop-in button that flips between light and dark mode.
|
|
15
|
+
*
|
|
16
|
+
* Sits inside an `<EvoThemeProvider>`. The icon and animation
|
|
17
|
+
* automatically reflect the resolved theme.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <EvoThemeProvider>
|
|
22
|
+
* <EvoThemeToggle />
|
|
23
|
+
* </EvoThemeProvider>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export const EvoThemeToggle = ({
|
|
27
|
+
size = 'md',
|
|
28
|
+
ariaLabel = 'Toggle color theme',
|
|
29
|
+
className,
|
|
30
|
+
}: EvoThemeToggleProps) => {
|
|
31
|
+
const { resolvedTheme, toggleTheme } = useEvoTheme();
|
|
32
|
+
const isDark = resolvedTheme === 'dark';
|
|
33
|
+
|
|
34
|
+
const classes = [styles.toggle, styles[size], className].filter(Boolean).join(' ');
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
role="switch"
|
|
40
|
+
aria-checked={isDark}
|
|
41
|
+
aria-label={ariaLabel}
|
|
42
|
+
onClick={toggleTheme}
|
|
43
|
+
className={classes}
|
|
44
|
+
data-theme-state={resolvedTheme}
|
|
45
|
+
>
|
|
46
|
+
<span className={styles.track}>
|
|
47
|
+
<span className={styles.thumb}>
|
|
48
|
+
{/* Sun icon */}
|
|
49
|
+
<svg
|
|
50
|
+
className={styles.sun}
|
|
51
|
+
viewBox="0 0 24 24"
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
strokeWidth="2"
|
|
55
|
+
strokeLinecap="round"
|
|
56
|
+
strokeLinejoin="round"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
>
|
|
59
|
+
<circle cx="12" cy="12" r="4" />
|
|
60
|
+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
|
61
|
+
</svg>
|
|
62
|
+
{/* Moon icon */}
|
|
63
|
+
<svg
|
|
64
|
+
className={styles.moon}
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
strokeWidth="2"
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
strokeLinejoin="round"
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
>
|
|
73
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
74
|
+
</svg>
|
|
75
|
+
</span>
|
|
76
|
+
</span>
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
};
|