Skip to content

Commit 3570254

Browse files
authored
feat(theme): add change, toggle, and cycle functions (#21224)
1 parent 6ed36ff commit 3570254

File tree

6 files changed

+157
-4
lines changed

6 files changed

+157
-4
lines changed

packages/api-generator/src/locale/en/useTheme.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"exposed": {
3+
"change": "Change to a specific theme.",
4+
"toggle": "Toggle between two themes.",
5+
"cycle": "Cycle between all or a subset of themes.",
36
"computedThemes": "Object containing all parsed theme definitions.",
47
"current": "Current theme object.",
58
"global": "Reference to the global theme instance.",

packages/docs/src/data/new-in.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
{
2+
"useTheme": {
3+
"exposed": {
4+
"change": "3.9.0",
5+
"toggle": "3.9.0",
6+
"cycle": "3.9.0"
7+
}
8+
},
29
"VAppBar": {
310
"props": {
411
"scrollBehavior": "3.2.0"

packages/docs/src/data/page-to-api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@
173173
"features/scrolling": ["useGoTo"],
174174
"features/global-configuration": ["VDefaultsProvider"],
175175
"features/internationalization": ["useLocale", "VLocaleProvider"],
176-
"features/theme": ["VThemeProvider"],
176+
"features/theme": ["useTheme", "VThemeProvider"],
177177
"styles/transitions": [
178178
"VDialogBottomTransition",
179179
"VDialogTopTransition",

packages/docs/src/pages/en/features/theme.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,56 @@ export default createVuetify({
153153

154154
## Changing theme
155155

156-
This is used when you need to change the theme during runtime
156+
The theme instance has 3 functions to change the theme:
157+
158+
- **change**: Change to a specific name
159+
- **toggle**: Toggle between two themes / defaults to light and dark
160+
- **cycle**: Cycle between all or a specific subset of themes in any order
161+
162+
```html
163+
<template>
164+
<v-app>
165+
<v-main>
166+
<v-container>
167+
<!-- Toggle between Light / Dark -->
168+
<v-btn
169+
@click="theme.toggle()"
170+
text="Toggle Light / Dark"
171+
></v-btn>
172+
173+
<!-- Change to a specific theme -->
174+
<v-btn
175+
@click="theme.change('dark')"
176+
text="Change to Dark"
177+
></v-btn>
178+
179+
<!-- Cycle between all themes -->
180+
<v-btn
181+
@click="theme.cycle()"
182+
text="Cycle All Themes"
183+
></v-btn>
184+
185+
<!-- Cycle between specific themes -->
186+
<v-btn
187+
@click="theme.cycle(['custom', 'light', 'utopia'])"
188+
text="Cycle Specific Themes"
189+
></v-btn>
190+
</v-container>
191+
</v-main>
192+
</v-app>
193+
</template>
194+
195+
<script setup>
196+
import { useTheme } from 'vuetify'
197+
198+
const theme = useTheme()
199+
</script>
200+
```
201+
202+
<details>
203+
<summary>Usage before v3.9</summary>
204+
205+
In versions before v3.9, you manually change the global name value on the theme instance:
157206

158207
```html { resource="src/App.vue" }
159208
<template>
@@ -174,6 +223,10 @@ function toggleTheme () {
174223
</script>
175224
```
176225

226+
</details>
227+
228+
<br>
229+
177230
You should keep in mind that most of the Vuetify components support the **theme** prop. When used a new context is created for _that_ specific component and **all** of its children. In the following example, the [v-btn](/components/buttons/) uses the **dark** theme because it is applied to its parent [v-card](/components/cards/).
178231

179232
```html

packages/vuetify/src/composables/__tests__/theme.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,50 @@ describe('createTheme', () => {
228228
// Verify updateDOM is called during reactivity
229229
expect(mockUpdateHead).toHaveBeenCalled()
230230
})
231+
232+
it('should change, toggle, and cycle theme', async () => {
233+
const theme = createTheme({
234+
defaultTheme: 'light',
235+
themes: {
236+
custom: { dark: false },
237+
utopia: { dark: true },
238+
},
239+
})
240+
241+
// Test 1: Toggle through all available themes when no argument provided
242+
expect(theme.name.value).toBe('light')
243+
244+
theme.toggle()
245+
expect(theme.name.value).toBe('dark')
246+
247+
theme.toggle()
248+
expect(theme.name.value).toBe('light')
249+
250+
// Test 2: Change to a specific theme
251+
theme.change('dark')
252+
expect(theme.name.value).toBe('dark')
253+
254+
// Test 3: Cycle between a limited set of themes
255+
theme.cycle()
256+
expect(theme.name.value).toBe('custom')
257+
258+
theme.cycle()
259+
expect(theme.name.value).toBe('utopia')
260+
261+
theme.cycle()
262+
expect(theme.name.value).toBe('light')
263+
264+
// Test 4: Cycle between a subset of themes
265+
theme.cycle(['light', 'utopia'])
266+
expect(theme.name.value).toBe('utopia')
267+
268+
theme.cycle(['light', 'utopia'])
269+
expect(theme.name.value).toBe('light')
270+
271+
// Test 5: Error when changing to a non-existent theme
272+
const consoleMock = vi.spyOn(console, 'warn').mockImplementation(() => {})
273+
theme.change('nonexistent')
274+
expect(consoleMock).toHaveBeenCalledWith('[Vue warn]: Vuetify: Theme "nonexistent" not found on the Vuetify theme instance')
275+
consoleMock.mockReset()
276+
})
231277
})

packages/vuetify/src/composables/theme.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
watchEffect,
1010
} from 'vue'
1111
import {
12+
consoleWarn,
1213
createRange,
1314
darken,
15+
deprecate,
1416
getCurrentInstance,
1517
getForeground,
1618
getLuma,
@@ -88,6 +90,10 @@ interface OnColors {
8890
}
8991

9092
export interface ThemeInstance {
93+
change: (themeName: string) => void
94+
cycle: (themeArray?: string[]) => void
95+
toggle: (themeArray?: [string, string]) => void
96+
9197
readonly isDisabled: boolean
9298
readonly themes: Ref<Record<string, InternalThemeDefinition>>
9399

@@ -386,6 +392,9 @@ export function createTheme (options?: ThemeOptions): ThemeInstance & { install:
386392
return lines.map((str, i) => i === 0 ? str : ` ${str}`).join('')
387393
})
388394

395+
const themeClasses = computed(() => parsedOptions.isDisabled ? undefined : `v-theme--${name.value}`)
396+
const themeNames = computed(() => Object.keys(computedThemes.value))
397+
389398
function install (app: App) {
390399
if (parsedOptions.isDisabled) return
391400

@@ -430,10 +439,45 @@ export function createTheme (options?: ThemeOptions): ThemeInstance & { install:
430439
}
431440
}
432441

433-
const themeClasses = computed(() => parsedOptions.isDisabled ? undefined : `v-theme--${name.value}`)
442+
function change (themeName: string) {
443+
if (!themeNames.value.includes(themeName)) {
444+
consoleWarn(`Theme "${themeName}" not found on the Vuetify theme instance`)
445+
return
446+
}
447+
448+
name.value = themeName
449+
}
450+
451+
function cycle (themeArray: string[] = themeNames.value) {
452+
const currentIndex = themeArray.indexOf(name.value)
453+
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeArray.length
454+
455+
change(themeArray[nextIndex])
456+
}
457+
458+
function toggle (themeArray: [string, string] = ['light', 'dark']) {
459+
cycle(themeArray)
460+
}
461+
462+
const globalName = new Proxy(name, {
463+
get (target, prop) {
464+
return target[prop as keyof typeof target]
465+
},
466+
set (target, prop, val) {
467+
if (prop === 'value') {
468+
deprecate(`theme.global.name.value = ${val}`, `theme.change('${val}')`)
469+
}
470+
// @ts-expect-error
471+
target[prop] = val
472+
return true
473+
},
474+
}) as typeof name
434475

435476
return {
436477
install,
478+
change,
479+
cycle,
480+
toggle,
437481
isDisabled: parsedOptions.isDisabled,
438482
name,
439483
themes,
@@ -442,7 +486,7 @@ export function createTheme (options?: ThemeOptions): ThemeInstance & { install:
442486
themeClasses,
443487
styles,
444488
global: {
445-
name,
489+
name: globalName,
446490
current,
447491
},
448492
}

0 commit comments

Comments
 (0)