@badnpm/ui 0.0.0-stage → 0.0.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 +193 -2
- package/dist/cjs/components/button.cjs +41 -0
- package/dist/cjs/components/input.cjs +11 -0
- package/dist/cjs/components/theme-toggle.cjs +19 -0
- package/dist/cjs/hooks/use-hydrated.cjs +15 -0
- package/dist/cjs/index.cjs +30 -0
- package/dist/cjs/lib/preference.cjs +40 -0
- package/dist/cjs/providers/preference-provider.cjs +127 -0
- package/dist/cjs/providers/theme-provider.cjs +181 -0
- package/dist/cjs/types/components/button.d.ts +12 -0
- package/dist/cjs/types/components/index.d.ts +3 -0
- package/dist/cjs/types/components/input.d.ts +5 -0
- package/dist/cjs/types/components/theme-toggle.d.ts +14 -0
- package/dist/cjs/types/hooks/index.d.ts +1 -0
- package/dist/cjs/types/hooks/use-hydrated.d.ts +4 -0
- package/dist/cjs/types/index.d.ts +8 -0
- package/dist/cjs/types/lib/index.d.ts +2 -0
- package/dist/cjs/types/lib/preference.d.ts +85 -0
- package/dist/cjs/types/lib/utils.d.ts +1 -0
- package/dist/cjs/types/package.json +1 -0
- package/dist/cjs/types/providers/index.d.ts +2 -0
- package/dist/cjs/types/providers/preference-provider.d.ts +20 -0
- package/dist/cjs/types/providers/theme-provider.d.ts +25 -0
- package/dist/esm/components/button.js +38 -0
- package/dist/esm/components/input.js +9 -0
- package/dist/esm/components/theme-toggle.js +17 -0
- package/dist/esm/hooks/use-hydrated.js +13 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/lib/preference.js +35 -0
- package/dist/esm/providers/preference-provider.js +124 -0
- package/dist/esm/providers/theme-provider.js +178 -0
- package/dist/styles/body.css +7 -0
- package/dist/styles/font/LICENSE +93 -0
- package/dist/styles/font/files/11944884.woff2 +0 -0
- package/dist/styles/font/files/3f081d19.woff2 +0 -0
- package/dist/styles/font/files/aa4aa22f.woff2 +0 -0
- package/dist/styles/font/files/ba1a9b92.woff2 +0 -0
- package/dist/styles/font/files/eadd8e6c.woff2 +0 -0
- package/dist/styles/font/geist.css +2 -0
- package/dist/styles/index.css +2 -0
- package/dist/types/components/button.d.ts +12 -0
- package/dist/types/components/index.d.ts +3 -0
- package/dist/types/components/input.d.ts +5 -0
- package/dist/types/components/theme-toggle.d.ts +14 -0
- package/dist/types/hooks/index.d.ts +1 -0
- package/dist/types/hooks/use-hydrated.d.ts +4 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/lib/index.d.ts +2 -0
- package/dist/types/lib/preference.d.ts +85 -0
- package/dist/types/lib/utils.d.ts +1 -0
- package/dist/types/providers/index.d.ts +2 -0
- package/dist/types/providers/preference-provider.d.ts +20 -0
- package/dist/types/providers/theme-provider.d.ts +25 -0
- package/dist/umd/index.js +3541 -0
- package/package.json +81 -4
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
var react = require('react');
|
|
6
|
+
var useHydrated = require('../hooks/use-hydrated.cjs');
|
|
7
|
+
|
|
8
|
+
const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)';
|
|
9
|
+
const ThemeProviderContext = react.createContext(undefined);
|
|
10
|
+
// 校验存储值,同时将类型收窄为合法的主题选项。
|
|
11
|
+
function isTheme(value) {
|
|
12
|
+
return value === 'dark' || value === 'light' || value === 'system';
|
|
13
|
+
}
|
|
14
|
+
// 优先读取已保存的主题;值无效或存储不可用时使用默认主题。
|
|
15
|
+
function readTheme(storageKey, fallback) {
|
|
16
|
+
try {
|
|
17
|
+
const value = localStorage.getItem(storageKey);
|
|
18
|
+
return isTheme(value) ? value : fallback;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// 存储不可用时回退到默认主题,不影响组件初始化。
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// 持久化用户选择,存储失败时仍允许更新页面主题。
|
|
26
|
+
function persistTheme(storageKey, theme) {
|
|
27
|
+
try {
|
|
28
|
+
localStorage.setItem(storageKey, theme);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// 忽略存储异常,主题仍可在当前页面内切换。
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// 将用户选择解析为实际配色;system 每次读取当前系统偏好。
|
|
35
|
+
function resolveTheme(theme) {
|
|
36
|
+
if (theme !== 'system') {
|
|
37
|
+
return theme;
|
|
38
|
+
}
|
|
39
|
+
return window.matchMedia(COLOR_SCHEME_QUERY).matches ? 'dark' : 'light';
|
|
40
|
+
}
|
|
41
|
+
// 更新根元素的主题类名,并返回清理临时样式及动画帧的函数。
|
|
42
|
+
function applyTheme(theme, disableTransitionOnChange) {
|
|
43
|
+
const root = document.documentElement;
|
|
44
|
+
const resolvedTheme = resolveTheme(theme);
|
|
45
|
+
// 主题已生效且没有冲突类名时,跳过重复的 DOM 操作。
|
|
46
|
+
if (root.classList.contains(resolvedTheme) && !root.classList.contains(resolvedTheme === 'dark' ? 'light' : 'dark')) {
|
|
47
|
+
return () => { };
|
|
48
|
+
}
|
|
49
|
+
const style = disableTransitionOnChange ? document.createElement('style') : null;
|
|
50
|
+
if (style) {
|
|
51
|
+
style.textContent = '*,*::before,*::after{transition:none!important}';
|
|
52
|
+
document.head.appendChild(style);
|
|
53
|
+
// 强制浏览器计算样式,确保禁用过渡的规则先于主题切换生效。
|
|
54
|
+
window.getComputedStyle(root).getPropertyValue('transition-property');
|
|
55
|
+
}
|
|
56
|
+
root.classList.remove('light', 'dark');
|
|
57
|
+
root.classList.add(resolvedTheme);
|
|
58
|
+
if (!style) {
|
|
59
|
+
return () => { };
|
|
60
|
+
}
|
|
61
|
+
// 在禁用过渡期间计算新主题样式,再通过两次动画帧延迟移除规则。
|
|
62
|
+
window.getComputedStyle(root).getPropertyValue('color');
|
|
63
|
+
let frame = requestAnimationFrame(() => {
|
|
64
|
+
frame = requestAnimationFrame(() => style.remove());
|
|
65
|
+
});
|
|
66
|
+
// 重新应用主题或卸载时,取消待执行的帧并移除临时样式。
|
|
67
|
+
return () => {
|
|
68
|
+
cancelAnimationFrame(frame);
|
|
69
|
+
style.remove();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// 判断事件是否来自可编辑区域,避免主题快捷键干扰输入。
|
|
73
|
+
function isEditableTarget(target) {
|
|
74
|
+
if (!(target instanceof HTMLElement)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (target.isContentEditable) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return target.closest('input, textarea, select') !== null;
|
|
81
|
+
}
|
|
82
|
+
// 统一管理主题状态、页面样式及系统和跨标签页同步。
|
|
83
|
+
function ThemeProvider({ children, defaultTheme = 'system', storageKey = 'bad-ui:theme', disableTransitionOnChange = true, keyboardShortcut, }) {
|
|
84
|
+
const hydrated = useHydrated.useHydrated();
|
|
85
|
+
const [storedTheme, setThemeState] = react.useState(() => readTheme(storageKey, defaultTheme));
|
|
86
|
+
// 水合首帧与服务端一致,完成后才使用浏览器保存的主题。
|
|
87
|
+
const theme = hydrated ? storedTheme : defaultTheme;
|
|
88
|
+
// 所有主动切换共用此入口,统一更新存储和 React 状态。
|
|
89
|
+
const setTheme = react.useCallback((nextTheme) => {
|
|
90
|
+
persistTheme(storageKey, nextTheme);
|
|
91
|
+
setThemeState(nextTheme);
|
|
92
|
+
}, [storageKey]);
|
|
93
|
+
// 按需解析系统配色,按钮和快捷键共用同一切换逻辑。
|
|
94
|
+
const toggleTheme = react.useCallback(() => {
|
|
95
|
+
setTheme(resolveTheme(theme) === 'dark' ? 'light' : 'dark');
|
|
96
|
+
}, [theme, setTheme]);
|
|
97
|
+
// 水合完成后应用主题;服务端与水合首帧不访问系统配色或修改根元素。
|
|
98
|
+
react.useLayoutEffect(() => {
|
|
99
|
+
if (!hydrated)
|
|
100
|
+
return;
|
|
101
|
+
let cleanup = applyTheme(theme, disableTransitionOnChange);
|
|
102
|
+
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
|
|
103
|
+
const handleChange = () => {
|
|
104
|
+
cleanup();
|
|
105
|
+
cleanup = applyTheme(theme, disableTransitionOnChange);
|
|
106
|
+
};
|
|
107
|
+
// 仅跟随系统时订阅配色变化;显式选择深浅色后不再跟随。
|
|
108
|
+
if (theme === 'system') {
|
|
109
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
110
|
+
}
|
|
111
|
+
return () => {
|
|
112
|
+
mediaQuery.removeEventListener('change', handleChange);
|
|
113
|
+
cleanup();
|
|
114
|
+
};
|
|
115
|
+
}, [theme, disableTransitionOnChange, hydrated]);
|
|
116
|
+
react.useEffect(() => {
|
|
117
|
+
if (!hydrated || !keyboardShortcut)
|
|
118
|
+
return;
|
|
119
|
+
const shortcutKey = keyboardShortcut.toLowerCase();
|
|
120
|
+
// 忽略已处理事件、输入法组合和长按重复。
|
|
121
|
+
const handleKeyDown = (event) => {
|
|
122
|
+
if (event.defaultPrevented || event.isComposing || event.repeat) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (isEditableTarget(event.target)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (event.key.toLowerCase() !== shortcutKey) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
toggleTheme();
|
|
135
|
+
};
|
|
136
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
137
|
+
return () => {
|
|
138
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
139
|
+
};
|
|
140
|
+
}, [toggleTheme, hydrated, keyboardShortcut]);
|
|
141
|
+
react.useEffect(() => {
|
|
142
|
+
// storage 事件用于接收同源其他标签页的主题变更。
|
|
143
|
+
const handleStorageChange = (event) => {
|
|
144
|
+
if (event.storageArea !== localStorage) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// clear() 触发的事件 key 为 null,也需要回退到默认主题。
|
|
148
|
+
if (event.key !== null && event.key !== storageKey) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (isTheme(event.newValue)) {
|
|
152
|
+
setThemeState(event.newValue);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// 存储项被移除、清空或写入无效值时,恢复默认主题。
|
|
156
|
+
setThemeState(defaultTheme);
|
|
157
|
+
};
|
|
158
|
+
window.addEventListener('storage', handleStorageChange);
|
|
159
|
+
return () => {
|
|
160
|
+
window.removeEventListener('storage', handleStorageChange);
|
|
161
|
+
};
|
|
162
|
+
}, [defaultTheme, storageKey]);
|
|
163
|
+
// 保持上下文值的引用稳定,避免父组件重渲染引起无关更新。
|
|
164
|
+
const value = react.useMemo(() => ({
|
|
165
|
+
theme,
|
|
166
|
+
setTheme,
|
|
167
|
+
toggleTheme,
|
|
168
|
+
}), [theme, setTheme, toggleTheme]);
|
|
169
|
+
return jsxRuntime.jsx(ThemeProviderContext.Provider, { value: value, children: children });
|
|
170
|
+
}
|
|
171
|
+
// 获取主题上下文,并在缺少 Provider 时给出明确错误。
|
|
172
|
+
const useTheme = () => {
|
|
173
|
+
const context = react.useContext(ThemeProviderContext);
|
|
174
|
+
if (context === undefined) {
|
|
175
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
176
|
+
}
|
|
177
|
+
return context;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
exports.ThemeProvider = ThemeProvider;
|
|
181
|
+
exports.useTheme = useTheme;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import * as class_variance_authority_types from 'class-variance-authority/types';
|
|
3
|
+
import { Button as Button$1 } from '@base-ui/react/button';
|
|
4
|
+
import { VariantProps } from 'class-variance-authority';
|
|
5
|
+
|
|
6
|
+
declare const buttonVariants: (props?: ({
|
|
7
|
+
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive" | "link" | null | undefined;
|
|
8
|
+
size?: "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
|
|
9
|
+
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
10
|
+
declare function Button({ className, variant, size, ...props }: Button$1.Props & VariantProps<typeof buttonVariants>): react.JSX.Element;
|
|
11
|
+
|
|
12
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentProps } from 'react';
|
|
3
|
+
import { Button } from './button.js';
|
|
4
|
+
|
|
5
|
+
type ThemeToggleProps = Omit<ComponentProps<typeof Button>, 'children'> & {
|
|
6
|
+
/** 切换到深色模式的无障碍文案,默认为“切换到深色主题”。 */
|
|
7
|
+
darkLabel?: string;
|
|
8
|
+
/** 切换到浅色模式的无障碍文案,默认为“切换到浅色主题”。 */
|
|
9
|
+
lightLabel?: string;
|
|
10
|
+
};
|
|
11
|
+
declare function ThemeToggle({ darkLabel, lightLabel, variant, size, title, onClick, ...props }: ThemeToggleProps): react.JSX.Element;
|
|
12
|
+
|
|
13
|
+
export { ThemeToggle };
|
|
14
|
+
export type { ThemeToggleProps };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useHydrated } from './use-hydrated.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Button, buttonVariants } from './components/button.js';
|
|
2
|
+
export { Input } from './components/input.js';
|
|
3
|
+
export { ThemeToggle, ThemeToggleProps } from './components/theme-toggle.js';
|
|
4
|
+
export { PreferenceProvider, usePreference } from './providers/preference-provider.js';
|
|
5
|
+
export { ThemeProvider, useTheme } from './providers/theme-provider.js';
|
|
6
|
+
export { useHydrated } from './hooks/use-hydrated.js';
|
|
7
|
+
export { BASE_COLORS, BaseColor, COLOR_THEMES, ColorTheme, DEFAULT_PREFERENCE, Preference, RADIUS_OPTIONS } from './lib/preference.js';
|
|
8
|
+
export { cn } from 'cn';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
declare const BASE_COLORS: readonly [{
|
|
2
|
+
readonly value: "neutral";
|
|
3
|
+
readonly label: "Neutral";
|
|
4
|
+
readonly color: "oklch(0.556 0 0)";
|
|
5
|
+
}, {
|
|
6
|
+
readonly value: "mist";
|
|
7
|
+
readonly label: "Mist";
|
|
8
|
+
readonly color: "oklch(0.56 0.021 213.5)";
|
|
9
|
+
}, {
|
|
10
|
+
readonly value: "zinc";
|
|
11
|
+
readonly label: "Zinc";
|
|
12
|
+
readonly color: "oklch(0.552 0.016 285.938)";
|
|
13
|
+
}];
|
|
14
|
+
declare const COLOR_THEMES: readonly [{
|
|
15
|
+
readonly value: "neutral";
|
|
16
|
+
readonly label: "Neutral";
|
|
17
|
+
readonly color: "oklch(0.205 0 0)";
|
|
18
|
+
}, {
|
|
19
|
+
readonly value: "blue";
|
|
20
|
+
readonly label: "Blue";
|
|
21
|
+
readonly color: "oklch(0.488 0.243 264.376)";
|
|
22
|
+
}, {
|
|
23
|
+
readonly value: "green";
|
|
24
|
+
readonly label: "Green";
|
|
25
|
+
readonly color: "oklch(0.527 0.154 150.069)";
|
|
26
|
+
}, {
|
|
27
|
+
readonly value: "orange";
|
|
28
|
+
readonly label: "Orange";
|
|
29
|
+
readonly color: "oklch(0.553 0.195 38.402)";
|
|
30
|
+
}, {
|
|
31
|
+
readonly value: "pink";
|
|
32
|
+
readonly label: "Pink";
|
|
33
|
+
readonly color: "oklch(0.525 0.223 3.958)";
|
|
34
|
+
}, {
|
|
35
|
+
readonly value: "purple";
|
|
36
|
+
readonly label: "Purple";
|
|
37
|
+
readonly color: "oklch(0.496 0.265 301.924)";
|
|
38
|
+
}, {
|
|
39
|
+
readonly value: "red";
|
|
40
|
+
readonly label: "Red";
|
|
41
|
+
readonly color: "oklch(0.505 0.213 27.518)";
|
|
42
|
+
}, {
|
|
43
|
+
readonly value: "rose";
|
|
44
|
+
readonly label: "Rose";
|
|
45
|
+
readonly color: "oklch(0.514 0.222 16.935)";
|
|
46
|
+
}, {
|
|
47
|
+
readonly value: "sky";
|
|
48
|
+
readonly label: "Sky";
|
|
49
|
+
readonly color: "oklch(0.5 0.134 242.749)";
|
|
50
|
+
}, {
|
|
51
|
+
readonly value: "yellow";
|
|
52
|
+
readonly label: "Yellow";
|
|
53
|
+
readonly color: "oklch(0.852 0.199 91.936)";
|
|
54
|
+
}];
|
|
55
|
+
declare const RADIUS_OPTIONS: readonly [{
|
|
56
|
+
readonly value: 0;
|
|
57
|
+
readonly label: "0";
|
|
58
|
+
}, {
|
|
59
|
+
readonly value: 0.25;
|
|
60
|
+
readonly label: "0.25";
|
|
61
|
+
}, {
|
|
62
|
+
readonly value: 0.5;
|
|
63
|
+
readonly label: "0.5";
|
|
64
|
+
}, {
|
|
65
|
+
readonly value: 0.625;
|
|
66
|
+
readonly label: "Default";
|
|
67
|
+
}, {
|
|
68
|
+
readonly value: 0.75;
|
|
69
|
+
readonly label: "0.75";
|
|
70
|
+
}, {
|
|
71
|
+
readonly value: 1;
|
|
72
|
+
readonly label: "1";
|
|
73
|
+
}];
|
|
74
|
+
type BaseColor = (typeof BASE_COLORS)[number]['value'];
|
|
75
|
+
type ColorTheme = (typeof COLOR_THEMES)[number]['value'];
|
|
76
|
+
type Preference = {
|
|
77
|
+
baseColor: BaseColor;
|
|
78
|
+
theme: ColorTheme;
|
|
79
|
+
/** 根字号单位 rem;0 表示直角,默认 0.625。 */
|
|
80
|
+
radius: number;
|
|
81
|
+
};
|
|
82
|
+
declare const DEFAULT_PREFERENCE: Preference;
|
|
83
|
+
|
|
84
|
+
export { BASE_COLORS, COLOR_THEMES, DEFAULT_PREFERENCE, RADIUS_OPTIONS };
|
|
85
|
+
export type { BaseColor, ColorTheme, Preference };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cn } from 'cn';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { Preference, BaseColor, ColorTheme } from '../lib/preference.js';
|
|
4
|
+
|
|
5
|
+
type PreferenceProviderProps = {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/** 可只覆盖部分默认值;已有的有效存储值优先。 */
|
|
8
|
+
defaultPreference?: Partial<Preference>;
|
|
9
|
+
/** 三项偏好共用的 localStorage 键,默认为 'bad-ui:preference'。 */
|
|
10
|
+
storageKey?: string;
|
|
11
|
+
};
|
|
12
|
+
type PreferenceContextValue = Preference & {
|
|
13
|
+
setBaseColor: (baseColor: BaseColor) => void;
|
|
14
|
+
setTheme: (theme: ColorTheme) => void;
|
|
15
|
+
setRadius: (radius: number) => void;
|
|
16
|
+
};
|
|
17
|
+
declare function PreferenceProvider({ children, defaultPreference, storageKey, }: PreferenceProviderProps): react.JSX.Element;
|
|
18
|
+
declare function usePreference(): PreferenceContextValue;
|
|
19
|
+
|
|
20
|
+
export { PreferenceProvider, usePreference };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
type Theme = 'dark' | 'light' | 'system';
|
|
5
|
+
type ThemeProviderProps = {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/** 没有有效的已保存主题时使用的模式,默认为 'system'(跟随系统配色)。 */
|
|
8
|
+
defaultTheme?: Theme;
|
|
9
|
+
/** 在 localStorage 中保存主题选择的键名,默认为 'bad-ui:theme'。 */
|
|
10
|
+
storageKey?: string;
|
|
11
|
+
/** 切换主题时是否短暂禁用全页 CSS transition,让新配色立即生效。默认为 true。 */
|
|
12
|
+
disableTransitionOnChange?: boolean;
|
|
13
|
+
/** 切换深浅主题的全局单键快捷键,使用 KeyboardEvent.key(如 'd' 或 'F8'),忽略大小写。省略时关闭。 */
|
|
14
|
+
keyboardShortcut?: string;
|
|
15
|
+
};
|
|
16
|
+
type ThemeProviderState = {
|
|
17
|
+
theme: Theme;
|
|
18
|
+
setTheme: (theme: Theme) => void;
|
|
19
|
+
/** 根据当前实际配色切换深浅模式,切换后停止跟随系统。 */
|
|
20
|
+
toggleTheme: () => void;
|
|
21
|
+
};
|
|
22
|
+
declare function ThemeProvider({ children, defaultTheme, storageKey, disableTransitionOnChange, keyboardShortcut, }: ThemeProviderProps): react.JSX.Element;
|
|
23
|
+
declare const useTheme: () => ThemeProviderState;
|
|
24
|
+
|
|
25
|
+
export { ThemeProvider, useTheme };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { Button as Button$1 } from '@base-ui/react/button';
|
|
3
|
+
import { cva } from 'class-variance-authority';
|
|
4
|
+
import { cn } from 'cn';
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
|
10
|
+
outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
|
11
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
|
12
|
+
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
|
13
|
+
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
|
14
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
18
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
19
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
20
|
+
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
21
|
+
icon: 'size-8',
|
|
22
|
+
'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
23
|
+
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
|
24
|
+
'icon-lg': 'size-9',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
variant: 'default',
|
|
29
|
+
size: 'default',
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
function Button({ className, variant = 'default', size = 'default', ...props }) {
|
|
33
|
+
return (jsx(Button$1, { "data-slot": "button", className: typeof className === 'function'
|
|
34
|
+
? (state) => cn(buttonVariants({ variant, size }), className(state))
|
|
35
|
+
: cn(buttonVariants({ variant, size }), className), ...props }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { Input as Input$1 } from '@base-ui/react/input';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
|
|
5
|
+
function Input({ className, type, ...props }) {
|
|
6
|
+
return (jsx(Input$1, { type: type, "data-slot": "input", className: cn("h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export { Input };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
3
|
+
import { MoonIcon, SunIcon } from 'lucide-react';
|
|
4
|
+
import { Button } from './button.js';
|
|
5
|
+
import { useTheme } from '../providers/theme-provider.js';
|
|
6
|
+
|
|
7
|
+
function ThemeToggle({ darkLabel = '切换到深色主题', lightLabel = '切换到浅色主题', variant = 'outline', size = 'icon', title = '切换主题', onClick, ...props }) {
|
|
8
|
+
const { toggleTheme } = useTheme();
|
|
9
|
+
return (jsxs(Button, { type: "button", variant: variant, size: size, title: title, ...props, onClick: (event) => {
|
|
10
|
+
// 先执行使用方回调,允许通过 preventDefault() 取消主题切换。
|
|
11
|
+
onClick?.(event);
|
|
12
|
+
if (!event.defaultPrevented)
|
|
13
|
+
toggleTheme();
|
|
14
|
+
}, children: [jsx(MoonIcon, { "data-icon": "inline-start", "aria-hidden": "true", className: "animate-in duration-200 ease-out zoom-in-75 fade-in spin-in-90 motion-reduce:animate-none dark:hidden" }), jsx(SunIcon, { "data-icon": "inline-start", "aria-hidden": "true", className: "hidden animate-in duration-200 ease-out zoom-in-75 fade-in spin-in-90 motion-reduce:animate-none dark:block" }), jsx("span", { className: "sr-only dark:hidden", children: darkLabel }), jsx("span", { className: "sr-only hidden dark:block", children: lightLabel })] }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { ThemeToggle };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from 'react';
|
|
3
|
+
|
|
4
|
+
// 水合完成与否由 React 的服务端快照机制区分,无需 effect 中同步设置状态。
|
|
5
|
+
const subscribe = () => () => { };
|
|
6
|
+
const getSnapshot = () => true;
|
|
7
|
+
const getServerSnapshot = () => false;
|
|
8
|
+
/** 服务端及客户端水合首帧返回 false,之后返回 true;纯客户端渲染直接返回 true。 */
|
|
9
|
+
function useHydrated() {
|
|
10
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { useHydrated };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Button, buttonVariants } from './components/button.js';
|
|
2
|
+
export { Input } from './components/input.js';
|
|
3
|
+
export { ThemeToggle } from './components/theme-toggle.js';
|
|
4
|
+
export { PreferenceProvider, usePreference } from './providers/preference-provider.js';
|
|
5
|
+
export { ThemeProvider, useTheme } from './providers/theme-provider.js';
|
|
6
|
+
export { useHydrated } from './hooks/use-hydrated.js';
|
|
7
|
+
export { BASE_COLORS, COLOR_THEMES, DEFAULT_PREFERENCE, RADIUS_OPTIONS } from './lib/preference.js';
|
|
8
|
+
export { cn } from 'cn';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// color 用于选项色块预览:基础色取浅色模式的 muted-foreground,主题色取 primary。
|
|
2
|
+
const BASE_COLORS = [
|
|
3
|
+
{ value: 'neutral', label: 'Neutral', color: 'oklch(0.556 0 0)' },
|
|
4
|
+
{ value: 'mist', label: 'Mist', color: 'oklch(0.56 0.021 213.5)' },
|
|
5
|
+
{ value: 'zinc', label: 'Zinc', color: 'oklch(0.552 0.016 285.938)' },
|
|
6
|
+
];
|
|
7
|
+
// value 对应 CSS 的 data-theme 属性,label 用于展示,color 是固定的预览色。
|
|
8
|
+
const COLOR_THEMES = [
|
|
9
|
+
{ value: 'neutral', label: 'Neutral', color: 'oklch(0.205 0 0)' },
|
|
10
|
+
{ value: 'blue', label: 'Blue', color: 'oklch(0.488 0.243 264.376)' },
|
|
11
|
+
{ value: 'green', label: 'Green', color: 'oklch(0.527 0.154 150.069)' },
|
|
12
|
+
{ value: 'orange', label: 'Orange', color: 'oklch(0.553 0.195 38.402)' },
|
|
13
|
+
{ value: 'pink', label: 'Pink', color: 'oklch(0.525 0.223 3.958)' },
|
|
14
|
+
{ value: 'purple', label: 'Purple', color: 'oklch(0.496 0.265 301.924)' },
|
|
15
|
+
{ value: 'red', label: 'Red', color: 'oklch(0.505 0.213 27.518)' },
|
|
16
|
+
{ value: 'rose', label: 'Rose', color: 'oklch(0.514 0.222 16.935)' },
|
|
17
|
+
{ value: 'sky', label: 'Sky', color: 'oklch(0.5 0.134 242.749)' },
|
|
18
|
+
{ value: 'yellow', label: 'Yellow', color: 'oklch(0.852 0.199 91.936)' },
|
|
19
|
+
];
|
|
20
|
+
// 圆角预设的 value 单位为 rem,可直接传给 setRadius,也可用于 borderRadius 预览。
|
|
21
|
+
const RADIUS_OPTIONS = [
|
|
22
|
+
{ value: 0, label: '0' },
|
|
23
|
+
{ value: 0.25, label: '0.25' },
|
|
24
|
+
{ value: 0.5, label: '0.5' },
|
|
25
|
+
{ value: 0.625, label: 'Default' },
|
|
26
|
+
{ value: 0.75, label: '0.75' },
|
|
27
|
+
{ value: 1, label: '1' },
|
|
28
|
+
];
|
|
29
|
+
const DEFAULT_PREFERENCE = {
|
|
30
|
+
baseColor: 'neutral',
|
|
31
|
+
theme: 'neutral',
|
|
32
|
+
radius: 0.625,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export { BASE_COLORS, COLOR_THEMES, DEFAULT_PREFERENCE, RADIUS_OPTIONS };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
3
|
+
import { createContext, useMemo, useState, useRef, useCallback, useLayoutEffect, useEffect, useContext } from 'react';
|
|
4
|
+
import { useHydrated } from '../hooks/use-hydrated.js';
|
|
5
|
+
import { COLOR_THEMES, BASE_COLORS, DEFAULT_PREFERENCE } from '../lib/preference.js';
|
|
6
|
+
|
|
7
|
+
const PreferenceContext = createContext(undefined);
|
|
8
|
+
// 校验选项标识,并将 unknown 收窄为对应的字符串联合类型。
|
|
9
|
+
function isOption(options, value) {
|
|
10
|
+
return options.some((option) => option.value === value);
|
|
11
|
+
}
|
|
12
|
+
// 存储内容不可信:逐项校验,单个字段无效时不影响其他有效偏好。
|
|
13
|
+
function normalizePreference(value, fallback) {
|
|
14
|
+
const candidate = typeof value === 'object' && value !== null ? value : {};
|
|
15
|
+
return {
|
|
16
|
+
baseColor: isOption(BASE_COLORS, candidate.baseColor) ? candidate.baseColor : fallback.baseColor,
|
|
17
|
+
theme: isOption(COLOR_THEMES, candidate.theme) ? candidate.theme : fallback.theme,
|
|
18
|
+
// 预设用于展示,不限制自定义圆角;只接受有限的非负数。
|
|
19
|
+
radius: typeof candidate.radius === 'number' && Number.isFinite(candidate.radius) && candidate.radius >= 0
|
|
20
|
+
? candidate.radius
|
|
21
|
+
: fallback.radius,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// 解析并校验存储字符串;没有记录或 JSON 损坏时回退到默认值。
|
|
25
|
+
function parsePreference(value, fallback) {
|
|
26
|
+
try {
|
|
27
|
+
return normalizePreference(value === null ? null : JSON.parse(value), fallback);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// 将存储访问与 JSON 解析分开处理,兼容浏览器禁止存储的情况。
|
|
34
|
+
function readPreference(storageKey, fallback) {
|
|
35
|
+
try {
|
|
36
|
+
return parsePreference(localStorage.getItem(storageKey), fallback);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// 存储不可用时,偏好仍可在当前页面内使用。
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 比较字段值,避免相同偏好触发重复渲染。
|
|
44
|
+
function isSamePreference(current, next) {
|
|
45
|
+
return current.baseColor === next.baseColor && current.theme === next.theme && current.radius === next.radius;
|
|
46
|
+
}
|
|
47
|
+
// 管理基础色、主题配色和圆角;深浅模式继续由 ThemeProvider 管理。
|
|
48
|
+
function PreferenceProvider({ children, defaultPreference, storageKey = 'bad-ui:preference', }) {
|
|
49
|
+
// 先补齐并校验调用方传入的默认值,再作为存储读取和重置时的回退值。
|
|
50
|
+
const { baseColor, theme, radius } = normalizePreference(defaultPreference, DEFAULT_PREFERENCE);
|
|
51
|
+
const defaults = useMemo(() => ({ baseColor, theme, radius }), [baseColor, theme, radius]);
|
|
52
|
+
const hydrated = useHydrated();
|
|
53
|
+
const [storedPreference, setPreference] = useState(() => readPreference(storageKey, defaults));
|
|
54
|
+
// 保存最新偏好,保证同一批次连续修改不同字段时保留先前修改。
|
|
55
|
+
const preferenceRef = useRef(storedPreference);
|
|
56
|
+
// 服务端及客户端水合首帧使用相同默认值,水合后才向子组件公开本地偏好。
|
|
57
|
+
const preference = hydrated ? storedPreference : defaults;
|
|
58
|
+
// 主动修改时才写入存储;跨标签页同步仅更新内存,避免删除后又写回默认值。
|
|
59
|
+
const updatePreference = useCallback((patch) => {
|
|
60
|
+
const current = preferenceRef.current;
|
|
61
|
+
const next = normalizePreference({ ...current, ...patch }, current);
|
|
62
|
+
if (isSamePreference(current, next))
|
|
63
|
+
return;
|
|
64
|
+
preferenceRef.current = next;
|
|
65
|
+
setPreference(next);
|
|
66
|
+
try {
|
|
67
|
+
localStorage.setItem(storageKey, JSON.stringify(next));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// 写入失败不阻断页面偏好更新。
|
|
71
|
+
}
|
|
72
|
+
}, [storageKey]);
|
|
73
|
+
const setBaseColor = useCallback((nextBaseColor) => {
|
|
74
|
+
updatePreference({ baseColor: nextBaseColor });
|
|
75
|
+
}, [updatePreference]);
|
|
76
|
+
// 此处 theme 是配色方案(如 blue),并非 light / dark / system。
|
|
77
|
+
const setTheme = useCallback((nextTheme) => {
|
|
78
|
+
updatePreference({ theme: nextTheme });
|
|
79
|
+
}, [updatePreference]);
|
|
80
|
+
// 圆角以 rem 保存,转换为 CSS 长度的工作由下方 effect 统一完成。
|
|
81
|
+
const setRadius = useCallback((nextRadius) => {
|
|
82
|
+
updatePreference({ radius: nextRadius });
|
|
83
|
+
}, [updatePreference]);
|
|
84
|
+
// 配色通过独立属性组合;圆角以内联变量覆盖主题默认值,不改动深浅模式类名。
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
if (!hydrated)
|
|
87
|
+
return;
|
|
88
|
+
const root = document.documentElement;
|
|
89
|
+
// 在浏览器绘制前更新属性,分别匹配 base-color 和 themes 中的选择器。
|
|
90
|
+
root.dataset.baseColor = preference.baseColor;
|
|
91
|
+
root.dataset.theme = preference.theme;
|
|
92
|
+
root.style.setProperty('--radius', `${preference.radius}rem`);
|
|
93
|
+
}, [preference, hydrated]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
// 接收其他标签页的变更;删除或清空存储时恢复默认偏好。
|
|
96
|
+
function handleStorageChange(event) {
|
|
97
|
+
// 只处理 localStorage 中当前键的变化;clear() 的事件 key 为 null。
|
|
98
|
+
if (event.storageArea !== localStorage || (event.key !== null && event.key !== storageKey)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const next = parsePreference(event.newValue, defaults);
|
|
102
|
+
if (isSamePreference(preferenceRef.current, next))
|
|
103
|
+
return;
|
|
104
|
+
preferenceRef.current = next;
|
|
105
|
+
setPreference(next);
|
|
106
|
+
}
|
|
107
|
+
window.addEventListener('storage', handleStorageChange);
|
|
108
|
+
// 卸载或依赖变化时注销监听,避免重复订阅。
|
|
109
|
+
return () => window.removeEventListener('storage', handleStorageChange);
|
|
110
|
+
}, [defaults, storageKey]);
|
|
111
|
+
// 保持 Context 值的引用稳定,避免父组件重渲染引起无关的订阅更新。
|
|
112
|
+
const value = useMemo(() => ({ ...preference, setBaseColor, setTheme, setRadius }), [preference, setBaseColor, setTheme, setRadius]);
|
|
113
|
+
return jsx(PreferenceContext.Provider, { value: value, children: children });
|
|
114
|
+
}
|
|
115
|
+
// 统一读取偏好上下文,在缺少 Provider 时给出明确的使用错误。
|
|
116
|
+
function usePreference() {
|
|
117
|
+
const context = useContext(PreferenceContext);
|
|
118
|
+
if (context === undefined) {
|
|
119
|
+
throw new Error('usePreference must be used within a PreferenceProvider');
|
|
120
|
+
}
|
|
121
|
+
return context;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { PreferenceProvider, usePreference };
|