@vobs/theme 1.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 +58 -0
- package/package.json +20 -0
- package/src/index.test.ts +131 -0
- package/src/index.ts +408 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @vobs/theme
|
|
2
|
+
|
|
3
|
+
Token-based theming with light/dark/system modes, CSS variable output, and scoped theme boundaries.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/theme
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createTheme } from '@vobs/theme'
|
|
15
|
+
|
|
16
|
+
const theme = createTheme({
|
|
17
|
+
defaultMode: 'system',
|
|
18
|
+
themes: {
|
|
19
|
+
light: { brand: { primary: '#2563eb' } },
|
|
20
|
+
dark: { brand: { primary: '#60a5fa' } }
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
theme.resolvedMode.value // 'light' or 'dark', follows prefers-color-scheme
|
|
25
|
+
theme.setMode('dark')
|
|
26
|
+
theme.theme.value.brand.primary // '#60a5fa'
|
|
27
|
+
|
|
28
|
+
const unregister = theme.registerTheme('dark', { density: 'compact' })
|
|
29
|
+
theme.setBrand({ secondary: '#0f766e' })
|
|
30
|
+
unregister()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
| Signature | Description |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `createTheme(options?: ThemeOptions): ThemeContext` | Creates a theme context; `defaultMode` defaults to `'light'`, themes merge over the built-in defaults. |
|
|
38
|
+
| `defaultLightTheme` / `defaultDarkTheme` | Built-in token sets covering brand, semantic, neutral, spacing, and radius tokens. |
|
|
39
|
+
| `theme.theme: Signal<ThemeTokens>` | Merged tokens for the resolved mode plus overrides. |
|
|
40
|
+
| `theme.brand: Signal<BrandTokens>` | The `brand` branch of the current theme. |
|
|
41
|
+
| `theme.mode: Signal<ThemeMode>` | `'light'`, `'dark'`, or `'system'`. |
|
|
42
|
+
| `theme.resolvedMode: Signal<ResolvedThemeMode>` | Concrete `'light'` or `'dark'`; follows the OS when mode is `'system'`. |
|
|
43
|
+
| `theme.setMode(mode: ThemeMode): void` | Switches the mode. |
|
|
44
|
+
| `theme.setTheme(tokens: ThemeTokens): void` | Replaces the user override tokens. |
|
|
45
|
+
| `theme.setBrand(brand: Partial<BrandTokens>): void` | Merges brand overrides into the current tokens. |
|
|
46
|
+
| `theme.registerTheme(mode: ResolvedThemeMode, tokens: ThemeTokens): () => void` | Extends a mode's tokens at runtime; returns a restore function. |
|
|
47
|
+
| `theme.dehydrate()` / `theme.hydrate(snapshot)` | Serializes and restores mode and overrides for SSR. |
|
|
48
|
+
| `theme.dispose(): void` | Stops the system preference listener and disposes signals. |
|
|
49
|
+
| `themePlugin(options?): VobsPlugin` | Provides the context through `THEME_KEY`. |
|
|
50
|
+
| `useTheme(): ThemeContext` | Injects the theme context inside components. |
|
|
51
|
+
| `ThemeBoundary(props?: ThemeBoundaryProps): VobsNode` | Renders a scoped element, writes its CSS variables, and overrides tokens for its subtree. |
|
|
52
|
+
| `flattenTheme(theme: ThemeTokens): Record<string, string>` | Flattens nested tokens into CSS variable names. |
|
|
53
|
+
|
|
54
|
+
With `mode` set to `'system'`, `resolvedMode` tracks the OS `prefers-color-scheme` through a live `matchMedia` listener. Nested tokens flatten to `--vobs-*` CSS variables (`brand.primary` becomes `--vobs-brand-primary`); keys that already start with `--` are kept as-is. A `ThemeBoundary` merges the parent theme with its own `theme`, `tokens`, and `brand` props, so overrides stay local to its subtree.
|
|
55
|
+
|
|
56
|
+
## Types
|
|
57
|
+
|
|
58
|
+
`ThemeMode`, `ResolvedThemeMode`, `ThemePrimitive`, `ThemeTokenValue`, `ThemeTokens`, `BrandTokens`, `ThemeOptions`, `ThemeContext`, `ThemePluginOptions`, `ThemeBoundaryProps`, `ThemeDehydratedState`
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/theme",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@vobs/vobs": "1.0.0",
|
|
18
|
+
"@vobs/reactivity": "1.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createComponent,
|
|
4
|
+
createDOMRenderer,
|
|
5
|
+
createElement,
|
|
6
|
+
createText,
|
|
7
|
+
createVobs,
|
|
8
|
+
bindText,
|
|
9
|
+
insertBefore,
|
|
10
|
+
setRenderer
|
|
11
|
+
} from '@vobs/vobs'
|
|
12
|
+
import {
|
|
13
|
+
createTheme,
|
|
14
|
+
flattenTheme,
|
|
15
|
+
THEME_KEY,
|
|
16
|
+
ThemeBoundary,
|
|
17
|
+
themePlugin,
|
|
18
|
+
useTheme
|
|
19
|
+
} from './index'
|
|
20
|
+
|
|
21
|
+
describe('@vobs/theme', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
setRenderer(createDOMRenderer())
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('嵌套令牌映射为 --vobs CSS 变量,并保留自定义变量名', () => {
|
|
27
|
+
expect(flattenTheme({
|
|
28
|
+
brand: { primary: '#123456' },
|
|
29
|
+
spacing: { md: '8px' },
|
|
30
|
+
'--custom-color': '#fff'
|
|
31
|
+
})).toEqual({
|
|
32
|
+
'--vobs-brand-primary': '#123456',
|
|
33
|
+
'--vobs-spacing-md': '8px',
|
|
34
|
+
'--custom-color': '#fff'
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('默认主题提供可供布局层使用的 surface token', () => {
|
|
39
|
+
const theme = createTheme()
|
|
40
|
+
|
|
41
|
+
expect(theme.theme.value.neutral).toMatchObject({
|
|
42
|
+
background: '#ffffff',
|
|
43
|
+
surface: '#f8fafc'
|
|
44
|
+
})
|
|
45
|
+
theme.setMode('dark')
|
|
46
|
+
expect(theme.theme.value.neutral).toMatchObject({
|
|
47
|
+
background: '#111827',
|
|
48
|
+
surface: '#1f2937'
|
|
49
|
+
})
|
|
50
|
+
theme.dispose()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ThemeBoundary 写入初始 CSS 变量,切换 mode 后更新变量', () => {
|
|
54
|
+
const theme = createTheme({
|
|
55
|
+
defaultMode: 'light',
|
|
56
|
+
themes: {
|
|
57
|
+
light: { '--color-primary': '#ffffff' },
|
|
58
|
+
dark: { '--color-primary': '#000000' }
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
const container = document.createElement('main')
|
|
62
|
+
const app = createVobs({
|
|
63
|
+
render: () => createComponent(ThemeBoundary, { children: () => createElement('span') }),
|
|
64
|
+
plugins: [themePlugin({ theme })]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
app.mount(container)
|
|
68
|
+
const boundary = container.firstElementChild
|
|
69
|
+
expect(boundary?.getAttribute('style')).toContain('--color-primary: #ffffff')
|
|
70
|
+
theme.setMode('dark')
|
|
71
|
+
app.update()
|
|
72
|
+
expect(boundary?.getAttribute('style')).toContain('--color-primary: #000000')
|
|
73
|
+
app.destroy()
|
|
74
|
+
theme.dispose()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('setBrand 更新当前主题,局部 Boundary 只覆盖自己的子树', () => {
|
|
78
|
+
const theme = createTheme({ defaultMode: 'light' })
|
|
79
|
+
function BrandText() {
|
|
80
|
+
const text = createText('')
|
|
81
|
+
bindText(text, () => useTheme().brand.value.primary ?? '')
|
|
82
|
+
return text
|
|
83
|
+
}
|
|
84
|
+
const container = document.createElement('main')
|
|
85
|
+
const app = createVobs({
|
|
86
|
+
render: () => {
|
|
87
|
+
const root = createElement('section')
|
|
88
|
+
insertBefore(root, createComponent(ThemeBoundary, {
|
|
89
|
+
children: () => createComponent(BrandText, {})
|
|
90
|
+
}), null)
|
|
91
|
+
insertBefore(root, createComponent(ThemeBoundary, {
|
|
92
|
+
brand: { primary: '#ff0000' },
|
|
93
|
+
children: () => createComponent(BrandText, {})
|
|
94
|
+
}), null)
|
|
95
|
+
return root
|
|
96
|
+
},
|
|
97
|
+
plugins: [themePlugin({ theme })]
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
app.mount(container)
|
|
101
|
+
expect(container.textContent).toBe('#2563eb#ff0000')
|
|
102
|
+
theme.setBrand({ primary: '#00ff00' })
|
|
103
|
+
app.update()
|
|
104
|
+
expect(container.textContent).toBe('#00ff00#ff0000')
|
|
105
|
+
const boundaries = container.querySelectorAll('div')
|
|
106
|
+
expect(boundaries[0]?.style.getPropertyValue('--vobs-brand-primary')).toBe('#00ff00')
|
|
107
|
+
expect(boundaries[1]?.style.getPropertyValue('--vobs-brand-primary')).toBe('#ff0000')
|
|
108
|
+
app.destroy()
|
|
109
|
+
theme.dispose()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('支持 system mode 和动态注册主题', () => {
|
|
113
|
+
const theme = createTheme({ defaultMode: 'light' })
|
|
114
|
+
const unregister = theme.registerTheme('light', { '--custom': 'yes' })
|
|
115
|
+
expect(theme.theme.value['--custom']).toBe('yes')
|
|
116
|
+
unregister()
|
|
117
|
+
expect(theme.theme.value['--custom']).toBeUndefined()
|
|
118
|
+
theme.setMode('system')
|
|
119
|
+
expect(theme.resolvedMode.value === 'light' || theme.resolvedMode.value === 'dark').toBe(true)
|
|
120
|
+
theme.dispose()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('未安装主题插件时给出明确错误', () => {
|
|
124
|
+
const app = createVobs({ render: () => {
|
|
125
|
+
void useTheme()
|
|
126
|
+
return createText('')
|
|
127
|
+
}})
|
|
128
|
+
expect(() => app.mount(document.createElement('div'))).toThrow('themePlugin')
|
|
129
|
+
expect(THEME_KEY).toBeDefined()
|
|
130
|
+
})
|
|
131
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { getCurrentOwner, memo, onDispose, renderEffect, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
createElement,
|
|
4
|
+
createInjectionKey,
|
|
5
|
+
inject,
|
|
6
|
+
insertBefore,
|
|
7
|
+
provide,
|
|
8
|
+
type InjectionKey,
|
|
9
|
+
type VobsNode,
|
|
10
|
+
type VobsPlugin
|
|
11
|
+
} from '@vobs/vobs'
|
|
12
|
+
|
|
13
|
+
export type ThemeMode = 'light' | 'dark' | 'system'
|
|
14
|
+
export type ResolvedThemeMode = 'light' | 'dark'
|
|
15
|
+
export type ThemePrimitive = string | number | boolean
|
|
16
|
+
export type ThemeTokenValue = ThemePrimitive | ThemeTokens | undefined
|
|
17
|
+
|
|
18
|
+
export interface ThemeTokens {
|
|
19
|
+
readonly [key: string]: ThemeTokenValue
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BrandTokens {
|
|
23
|
+
readonly primary?: string
|
|
24
|
+
readonly secondary?: string
|
|
25
|
+
readonly accent?: string
|
|
26
|
+
readonly [key: string]: string | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ThemeOptions {
|
|
30
|
+
readonly defaultMode?: ThemeMode
|
|
31
|
+
readonly themes?: Partial<Record<ResolvedThemeMode, ThemeTokens>>
|
|
32
|
+
readonly defaultTheme?: ThemeTokens
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ThemeDehydratedState {
|
|
36
|
+
readonly version: 1
|
|
37
|
+
readonly mode: ThemeMode
|
|
38
|
+
readonly overrides: ThemeTokens
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ThemeContext {
|
|
42
|
+
readonly theme: Signal<ThemeTokens>
|
|
43
|
+
readonly brand: Signal<BrandTokens>
|
|
44
|
+
readonly mode: Signal<ThemeMode>
|
|
45
|
+
readonly resolvedMode: Signal<ResolvedThemeMode>
|
|
46
|
+
dehydrate(): ThemeDehydratedState
|
|
47
|
+
hydrate(snapshot: unknown): void
|
|
48
|
+
setTheme(theme: ThemeTokens): void
|
|
49
|
+
setMode(mode: ThemeMode): void
|
|
50
|
+
setBrand(brand: Partial<BrandTokens>): void
|
|
51
|
+
registerTheme(mode: ResolvedThemeMode, theme: ThemeTokens): () => void
|
|
52
|
+
dispose(): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const THEME_KEY: InjectionKey<ThemeContext> = createInjectionKey<ThemeContext>('vobs.theme')
|
|
56
|
+
|
|
57
|
+
export interface ThemePluginOptions extends ThemeOptions {
|
|
58
|
+
readonly theme?: ThemeContext
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ThemeBoundaryProps {
|
|
62
|
+
readonly theme?: ThemeTokens
|
|
63
|
+
readonly tokens?: ThemeTokens
|
|
64
|
+
readonly brand?: Partial<BrandTokens>
|
|
65
|
+
readonly children?: VobsNode | (() => VobsNode | null | undefined)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const defaultLightTheme: ThemeTokens = {
|
|
69
|
+
colorScheme: 'light',
|
|
70
|
+
brand: {
|
|
71
|
+
primary: '#2563eb',
|
|
72
|
+
secondary: '#0f766e',
|
|
73
|
+
accent: '#d97706'
|
|
74
|
+
},
|
|
75
|
+
semantic: {
|
|
76
|
+
success: '#15803d',
|
|
77
|
+
warning: '#b45309',
|
|
78
|
+
danger: '#b91c1c',
|
|
79
|
+
info: '#0369a1'
|
|
80
|
+
},
|
|
81
|
+
neutral: {
|
|
82
|
+
background: '#ffffff',
|
|
83
|
+
surface: '#f8fafc',
|
|
84
|
+
foreground: '#172033',
|
|
85
|
+
muted: '#64748b',
|
|
86
|
+
border: '#cbd5e1'
|
|
87
|
+
},
|
|
88
|
+
spacing: {
|
|
89
|
+
sm: '4px',
|
|
90
|
+
md: '8px',
|
|
91
|
+
lg: '16px'
|
|
92
|
+
},
|
|
93
|
+
radius: {
|
|
94
|
+
sm: '2px',
|
|
95
|
+
md: '4px',
|
|
96
|
+
lg: '6px'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const defaultDarkTheme: ThemeTokens = {
|
|
101
|
+
colorScheme: 'dark',
|
|
102
|
+
brand: {
|
|
103
|
+
primary: '#60a5fa',
|
|
104
|
+
secondary: '#2dd4bf',
|
|
105
|
+
accent: '#fbbf24'
|
|
106
|
+
},
|
|
107
|
+
semantic: {
|
|
108
|
+
success: '#4ade80',
|
|
109
|
+
warning: '#fbbf24',
|
|
110
|
+
danger: '#f87171',
|
|
111
|
+
info: '#38bdf8'
|
|
112
|
+
},
|
|
113
|
+
neutral: {
|
|
114
|
+
background: '#111827',
|
|
115
|
+
surface: '#1f2937',
|
|
116
|
+
foreground: '#f8fafc',
|
|
117
|
+
muted: '#94a3b8',
|
|
118
|
+
border: '#334155'
|
|
119
|
+
},
|
|
120
|
+
spacing: {
|
|
121
|
+
sm: '4px',
|
|
122
|
+
md: '8px',
|
|
123
|
+
lg: '16px'
|
|
124
|
+
},
|
|
125
|
+
radius: {
|
|
126
|
+
sm: '2px',
|
|
127
|
+
md: '4px',
|
|
128
|
+
lg: '6px'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const appliedVariables = new WeakMap<HTMLElement, Set<string>>()
|
|
133
|
+
|
|
134
|
+
export function createTheme(options: ThemeOptions = {}): ThemeContext {
|
|
135
|
+
const mode = state<ThemeMode>(options.defaultMode ?? 'light')
|
|
136
|
+
const systemMode = state<ResolvedThemeMode>(readSystemMode())
|
|
137
|
+
const themes = state<Record<ResolvedThemeMode, ThemeTokens>>({
|
|
138
|
+
light: mergeThemes(defaultLightTheme, options.defaultTheme ?? {}, options.themes?.light ?? {}),
|
|
139
|
+
dark: mergeThemes(defaultDarkTheme, options.defaultTheme ?? {}, options.themes?.dark ?? {})
|
|
140
|
+
})
|
|
141
|
+
const overrides = state<ThemeTokens>({})
|
|
142
|
+
const resolvedMode = memo<ResolvedThemeMode>(() => mode.value === 'system' ? systemMode.value : mode.value)
|
|
143
|
+
const theme = memo<ThemeTokens>(() => mergeThemes(themes.value[resolvedMode.value], overrides.value))
|
|
144
|
+
const brand = memo<BrandTokens>(() => {
|
|
145
|
+
const value = theme.value.brand
|
|
146
|
+
return isThemeTokens(value) ? value as BrandTokens : {}
|
|
147
|
+
})
|
|
148
|
+
let disposed = false
|
|
149
|
+
let stopSystemListener: (() => void) | undefined
|
|
150
|
+
|
|
151
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
152
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
153
|
+
const listener = (event: MediaQueryListEvent): void => {
|
|
154
|
+
systemMode.value = event.matches ? 'dark' : 'light'
|
|
155
|
+
}
|
|
156
|
+
if (media.addEventListener) {
|
|
157
|
+
media.addEventListener('change', listener)
|
|
158
|
+
stopSystemListener = () => media.removeEventListener('change', listener)
|
|
159
|
+
} else {
|
|
160
|
+
media.addListener(listener)
|
|
161
|
+
stopSystemListener = () => media.removeListener(listener)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const context: ThemeContext = {
|
|
166
|
+
theme,
|
|
167
|
+
brand,
|
|
168
|
+
mode,
|
|
169
|
+
resolvedMode,
|
|
170
|
+
|
|
171
|
+
dehydrate(): ThemeDehydratedState {
|
|
172
|
+
ensureActive()
|
|
173
|
+
return { version: 1, mode: mode.value, overrides: overrides.value }
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
hydrate(snapshot: unknown): void {
|
|
177
|
+
ensureActive()
|
|
178
|
+
const restored = parseDehydratedState(snapshot)
|
|
179
|
+
mode.value = restored.mode
|
|
180
|
+
overrides.value = restored.overrides
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
setTheme(nextTheme: ThemeTokens): void {
|
|
184
|
+
ensureActive()
|
|
185
|
+
overrides.value = nextTheme
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
setMode(nextMode: ThemeMode): void {
|
|
189
|
+
ensureActive()
|
|
190
|
+
mode.value = validateMode(nextMode)
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
setBrand(nextBrand: Partial<BrandTokens>): void {
|
|
194
|
+
ensureActive()
|
|
195
|
+
overrides.value = mergeThemes(overrides.value, {
|
|
196
|
+
brand: {
|
|
197
|
+
...brand.value,
|
|
198
|
+
...nextBrand
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
registerTheme(nextMode: ResolvedThemeMode, nextTheme: ThemeTokens): () => void {
|
|
204
|
+
ensureActive()
|
|
205
|
+
const previous = themes.value[nextMode]
|
|
206
|
+
themes.value = {
|
|
207
|
+
...themes.value,
|
|
208
|
+
[nextMode]: mergeThemes(previous, nextTheme)
|
|
209
|
+
}
|
|
210
|
+
return () => {
|
|
211
|
+
if (disposed) return
|
|
212
|
+
themes.value = { ...themes.value, [nextMode]: previous }
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
dispose(): void {
|
|
217
|
+
if (disposed) return
|
|
218
|
+
disposed = true
|
|
219
|
+
stopSystemListener?.()
|
|
220
|
+
stopSystemListener = undefined
|
|
221
|
+
theme.dispose()
|
|
222
|
+
brand.dispose()
|
|
223
|
+
resolvedMode.dispose()
|
|
224
|
+
mode.dispose()
|
|
225
|
+
systemMode.dispose()
|
|
226
|
+
themes.dispose()
|
|
227
|
+
overrides.dispose()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (getCurrentOwner()) onDispose(context.dispose)
|
|
232
|
+
return context
|
|
233
|
+
|
|
234
|
+
function ensureActive(): void {
|
|
235
|
+
if (disposed) throw new Error('Vobs Theme: 已销毁的上下文不能继续使用')
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function themePlugin(options: ThemePluginOptions = {}): VobsPlugin {
|
|
240
|
+
return {
|
|
241
|
+
name: '@vobs/theme',
|
|
242
|
+
version: '0.1.0',
|
|
243
|
+
install(context) {
|
|
244
|
+
const ownedTheme = options.theme ? undefined : createTheme(options)
|
|
245
|
+
const theme = options.theme ?? ownedTheme!
|
|
246
|
+
context.provide(THEME_KEY, theme)
|
|
247
|
+
return () => ownedTheme?.dispose()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function useTheme(): ThemeContext {
|
|
253
|
+
const theme = inject(THEME_KEY)
|
|
254
|
+
if (!theme) throw new Error('Vobs Theme: 找不到上下文,请安装 themePlugin')
|
|
255
|
+
return theme
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function ThemeBoundary(props: ThemeBoundaryProps = {}): VobsNode {
|
|
259
|
+
const parent = useTheme()
|
|
260
|
+
const local = createScopedTheme(parent, mergeThemes(
|
|
261
|
+
props.theme ?? {},
|
|
262
|
+
props.tokens ?? {},
|
|
263
|
+
props.brand ? { brand: props.brand } : {}
|
|
264
|
+
))
|
|
265
|
+
provide(THEME_KEY, local)
|
|
266
|
+
|
|
267
|
+
const root = createElement('div') as HTMLElement
|
|
268
|
+
renderEffect(() => {
|
|
269
|
+
applyThemeVariables(root, local.theme.value)
|
|
270
|
+
root.dataset.vobsMode = local.resolvedMode.value
|
|
271
|
+
})
|
|
272
|
+
const child = typeof props.children === 'function' ? props.children() : props.children
|
|
273
|
+
if (child) insertBefore(root, child, null)
|
|
274
|
+
return root
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function flattenTheme(theme: ThemeTokens): Record<string, string> {
|
|
278
|
+
const variables: Record<string, string> = {}
|
|
279
|
+
flattenInto(theme, '', variables)
|
|
280
|
+
return variables
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function createScopedTheme(parent: ThemeContext, initialOverrides: ThemeTokens): ThemeContext {
|
|
284
|
+
const overrides = state<ThemeTokens>(initialOverrides)
|
|
285
|
+
const theme = memo<ThemeTokens>(() => mergeThemes(parent.theme.value, overrides.value))
|
|
286
|
+
const brand = memo<BrandTokens>(() => {
|
|
287
|
+
const value = theme.value.brand
|
|
288
|
+
return isThemeTokens(value) ? value as BrandTokens : {}
|
|
289
|
+
})
|
|
290
|
+
let disposed = false
|
|
291
|
+
|
|
292
|
+
const context: ThemeContext = {
|
|
293
|
+
theme,
|
|
294
|
+
brand,
|
|
295
|
+
mode: parent.mode,
|
|
296
|
+
resolvedMode: parent.resolvedMode,
|
|
297
|
+
dehydrate: parent.dehydrate,
|
|
298
|
+
hydrate: parent.hydrate,
|
|
299
|
+
setTheme(nextTheme): void {
|
|
300
|
+
ensureActive()
|
|
301
|
+
overrides.value = nextTheme
|
|
302
|
+
},
|
|
303
|
+
setMode(nextMode): void {
|
|
304
|
+
ensureActive()
|
|
305
|
+
parent.setMode(nextMode)
|
|
306
|
+
},
|
|
307
|
+
setBrand(nextBrand): void {
|
|
308
|
+
ensureActive()
|
|
309
|
+
overrides.value = mergeThemes(overrides.value, {
|
|
310
|
+
brand: { ...brand.value, ...nextBrand }
|
|
311
|
+
})
|
|
312
|
+
},
|
|
313
|
+
registerTheme(nextMode, nextTheme): () => void {
|
|
314
|
+
ensureActive()
|
|
315
|
+
return parent.registerTheme(nextMode, nextTheme)
|
|
316
|
+
},
|
|
317
|
+
dispose(): void {
|
|
318
|
+
if (disposed) return
|
|
319
|
+
disposed = true
|
|
320
|
+
theme.dispose()
|
|
321
|
+
brand.dispose()
|
|
322
|
+
overrides.dispose()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (getCurrentOwner()) onDispose(context.dispose)
|
|
326
|
+
return context
|
|
327
|
+
|
|
328
|
+
function ensureActive(): void {
|
|
329
|
+
if (disposed) throw new Error('Vobs Theme: 已销毁的局部上下文不能继续使用')
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseDehydratedState(snapshot: unknown): ThemeDehydratedState {
|
|
334
|
+
let value: unknown = snapshot
|
|
335
|
+
if (typeof snapshot === 'string') {
|
|
336
|
+
try {
|
|
337
|
+
value = JSON.parse(snapshot)
|
|
338
|
+
} catch {
|
|
339
|
+
throw new Error('Vobs Theme: 初始状态不是有效 JSON')
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!value || typeof value !== 'object') throw new Error('Vobs Theme: 初始状态格式无效')
|
|
343
|
+
const candidate = value as Partial<ThemeDehydratedState>
|
|
344
|
+
if (candidate.version !== 1 || !candidate.overrides || typeof candidate.overrides !== 'object') {
|
|
345
|
+
throw new Error('Vobs Theme: 初始状态版本或字段无效')
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
version: 1,
|
|
349
|
+
mode: validateMode(candidate.mode as ThemeMode),
|
|
350
|
+
overrides: candidate.overrides
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function applyThemeVariables(root: HTMLElement, theme: ThemeTokens): void {
|
|
355
|
+
const next = flattenTheme(theme)
|
|
356
|
+
const previous = appliedVariables.get(root) ?? new Set<string>()
|
|
357
|
+
for (const [key, value] of Object.entries(next)) {
|
|
358
|
+
root.style.setProperty(key, value)
|
|
359
|
+
previous.delete(key)
|
|
360
|
+
}
|
|
361
|
+
for (const key of previous) root.style.removeProperty(key)
|
|
362
|
+
appliedVariables.set(root, new Set(Object.keys(next)))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function flattenInto(value: ThemeTokenValue, path: string, output: Record<string, string>): void {
|
|
366
|
+
if (value === undefined) return
|
|
367
|
+
if (isThemeTokens(value)) {
|
|
368
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
369
|
+
const nextPath = key.startsWith('--') ? key : path ? `${path}-${key}` : key
|
|
370
|
+
flattenInto(nested, nextPath, output)
|
|
371
|
+
}
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
const variable = path.startsWith('--') ? path : `--vobs-${path}`
|
|
375
|
+
output[variable] = String(value)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function mergeThemes(...themes: ThemeTokens[]): ThemeTokens {
|
|
379
|
+
const result: Record<string, ThemeTokenValue> = {}
|
|
380
|
+
for (const theme of themes) {
|
|
381
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
382
|
+
const previous = result[key]
|
|
383
|
+
result[key] = isThemeTokens(previous) && isThemeTokens(value)
|
|
384
|
+
? mergeThemes(previous, value)
|
|
385
|
+
: value
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isThemeTokens(value: unknown): value is ThemeTokens {
|
|
392
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function validateMode(mode: ThemeMode): ThemeMode {
|
|
396
|
+
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') {
|
|
397
|
+
throw new Error('Vobs Theme: mode 必须是 light、dark 或 system')
|
|
398
|
+
}
|
|
399
|
+
return mode
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readSystemMode(): ResolvedThemeMode {
|
|
403
|
+
return typeof window !== 'undefined'
|
|
404
|
+
&& typeof window.matchMedia === 'function'
|
|
405
|
+
&& window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
406
|
+
? 'dark'
|
|
407
|
+
: 'light'
|
|
408
|
+
}
|