diff --git a/packages/docs/src/pages/en/getting-started/upgrade-guide.md b/packages/docs/src/pages/en/getting-started/upgrade-guide.md index b9f5a13974a..c9222a260e4 100644 --- a/packages/docs/src/pages/en/getting-started/upgrade-guide.md +++ b/packages/docs/src/pages/en/getting-started/upgrade-guide.md @@ -21,3 +21,30 @@ This page contains a detailed list of breaking changes and the steps required to - Returned Refs are now readonly. - Breakpoints are matched with `window.matchMedia` instead of `window.innerWidth`. This may result in slightly different values at zoom levels other than 100%. + +## Theme + +The theme system now uses `@vuetify/v0` under the hood. The consumer API (`useTheme`, `VThemeProvider`) is unchanged for most users. + +### ThemeInstance + +Several properties have been removed from the `ThemeInstance` type: + +- `styles` — CSS injection is now handled internally by the theme adapter +- `isDisabled` — themes are always enabled +- `isSystem` — check `name.value === 'system'` directly + +### Runtime theme changes + +Assigning new themes directly to `themes.value` is replaced by `register()`: + +```diff +- theme.themes.value.custom = { dark: true, colors: { primary: '#ff5722' } } ++ theme.register({ id: 'custom', dark: true, colors: { primary: '#ff5722' } }) +``` + +Mutating existing theme colors continues to work: + +```ts +theme.themes.value.light.colors.primary = '#ff0000' +``` diff --git a/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx b/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx index 66ca0cd7974..c6c396bbf0e 100644 --- a/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx +++ b/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx @@ -1,6 +1,9 @@ // Styles import './VThemeProvider.sass' +// Components +import { Theme } from '@vuetify/v0/components' + // Composables import { makeComponentProps } from '@/composables/component' import { makeTagProps } from '@/composables/tag' @@ -26,19 +29,27 @@ export const VThemeProvider = genericComponent()({ const { themeClasses } = provideTheme(props) return () => { - if (!props.withBackground) return slots.default?.() + if (!props.withBackground) { + return ( + + { slots.default?.() } + + ) + } return ( - - { slots.default?.() } - + + + { slots.default?.() } + + ) } }, diff --git a/packages/vuetify/src/composables/theme.ts b/packages/vuetify/src/composables/theme.ts deleted file mode 100644 index 4e764dd15bb..00000000000 --- a/packages/vuetify/src/composables/theme.ts +++ /dev/null @@ -1,592 +0,0 @@ -// Utilities -import { - computed, - getCurrentScope, - inject, - onScopeDispose, - provide, - ref, - shallowRef, - toRef, - watch, - watchEffect, -} from 'vue' -import { - consoleWarn, - createRange, - darken, - deprecate, - getCurrentInstance, - getLuma, - hasLightForeground, - IN_BROWSER, - lighten, - mergeDeep, - parseColor, - propsFactory, - RGBtoHex, - SUPPORTS_MATCH_MEDIA, -} from '@/util' - -// Types -import type { VueHeadClient } from '@unhead/vue/client' -import type { HeadClient } from '@vueuse/head' -import type { App, DeepReadonly, InjectionKey, Ref } from 'vue' -import type { Color } from '@/util' - -type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T - -export type ThemeOptions = false | { - cspNonce?: string - defaultTheme?: 'light' | 'dark' | 'system' | string & {} - variations?: false | VariationsOptions - themes?: Record - stylesheetId?: string - scope?: string - utilities?: boolean -} -export type ThemeDefinition = DeepPartial - -interface InternalThemeOptions { - cspNonce?: string - isDisabled: boolean - defaultTheme: 'light' | 'dark' | 'system' | string & {} - prefix: string - variations: false | VariationsOptions - themes: Record - stylesheetId: string - scope?: string - scoped: boolean - utilities: boolean -} - -interface VariationsOptions { - colors: string[] - lighten: number - darken: number -} - -interface InternalThemeDefinition { - dark: boolean - colors: Colors - variables: Record -} - -export interface Colors extends BaseColors, OnColors { - [key: string]: Color -} - -interface BaseColors { - background: Color - surface: Color - primary: Color - secondary: Color - success: Color - warning: Color - error: Color - info: Color -} - -interface OnColors { - 'on-background': Color - 'on-surface': Color - 'on-primary': Color - 'on-secondary': Color - 'on-success': Color - 'on-warning': Color - 'on-error': Color - 'on-info': Color -} - -export interface ThemeInstance { - change: (themeName: string) => void - cycle: (themeArray?: string[]) => void - toggle: (themeArray?: [string, string]) => void - - readonly isDisabled: boolean - readonly isSystem: Readonly> - readonly themes: Ref> - - readonly name: Readonly> - readonly current: DeepReadonly> - readonly computedThemes: DeepReadonly>> - readonly prefix: string - - readonly themeClasses: Readonly> - readonly styles: Readonly> - - readonly global: { - readonly name: Ref - readonly current: DeepReadonly> - } -} - -export const ThemeSymbol: InjectionKey = Symbol.for('vuetify:theme') - -export const makeThemeProps = propsFactory({ - theme: String, -}, 'theme') - -function genDefaults () { - return { - defaultTheme: 'system', - prefix: 'v-', - variations: { colors: [], lighten: 0, darken: 0 }, - themes: { - light: { - dark: false, - colors: { - background: '#FFFFFF', - surface: '#FFFFFF', - 'surface-bright': '#FFFFFF', - 'surface-light': '#EEEEEE', - 'surface-variant': '#424242', - 'on-surface-variant': '#EEEEEE', - primary: '#1867C0', - 'primary-darken-1': '#1F5592', - secondary: '#48A9A6', - 'secondary-darken-1': '#018786', - error: '#B00020', - info: '#2196F3', - success: '#4CAF50', - warning: '#FB8C00', - }, - variables: { - 'border-color': '#000000', - 'border-opacity': 0.12, - 'shadow-color': '#000000', - 'high-emphasis-opacity': 0.87, - 'medium-emphasis-opacity': 0.60, - 'disabled-opacity': 0.38, - 'idle-opacity': 0.04, - 'hover-opacity': 0.04, - 'focus-opacity': 0.12, - 'selected-opacity': 0.08, - 'activated-opacity': 0.12, - 'pressed-opacity': 0.12, - 'dragged-opacity': 0.08, - 'theme-kbd': '#EEEEEE', - 'theme-on-kbd': '#000000', - 'theme-code': '#F5F5F5', - 'theme-on-code': '#000000', - 'theme-on-dark': '#FFF', - 'theme-on-light': '#000', - 'elevation-overlay-color': 'black', - 'elevation-overlay-opacity-step': '2%', - }, - }, - dark: { - dark: true, - colors: { - background: '#121212', - surface: '#212121', - 'surface-bright': '#ccbfd6', - 'surface-light': '#424242', - 'surface-variant': '#c8c8c8', - 'on-surface-variant': '#000000', - primary: '#2196F3', - 'primary-darken-1': '#277CC1', - secondary: '#54B6B2', - 'secondary-darken-1': '#48A9A6', - error: '#CF6679', - info: '#2196F3', - success: '#4CAF50', - warning: '#FB8C00', - }, - variables: { - 'border-color': '#FFFFFF', - 'border-opacity': 0.12, - 'shadow-color': '#000000', - 'high-emphasis-opacity': 1, - 'medium-emphasis-opacity': 0.70, - 'disabled-opacity': 0.50, - 'idle-opacity': 0.10, - 'hover-opacity': 0.04, - 'focus-opacity': 0.12, - 'selected-opacity': 0.08, - 'activated-opacity': 0.12, - 'pressed-opacity': 0.16, - 'dragged-opacity': 0.08, - 'theme-kbd': '#424242', - 'theme-on-kbd': '#FFFFFF', - 'theme-code': '#343434', - 'theme-on-code': '#CCCCCC', - 'theme-on-dark': '#FFF', - 'theme-on-light': '#000', - 'elevation-overlay-color': 'white', - 'elevation-overlay-opacity-step': '2%', - }, - }, - }, - stylesheetId: 'vuetify-theme-stylesheet', - scoped: false, - utilities: true, - } -} - -function parseThemeOptions (options: ThemeOptions = genDefaults()): InternalThemeOptions { - const defaults = genDefaults() - - if (!options) return { ...defaults, isDisabled: true } as any - - return mergeDeep(defaults, options) as InternalThemeOptions -} - -function createCssClass (lines: string[], selector: string, content: string[], scope?: string) { - lines.push( - `${getScopedSelector(selector, scope)} {\n`, - ...content.map(line => ` ${line};\n`), - '}\n', - ) -} - -function genCssVariables (theme: InternalThemeDefinition, prefix: string) { - const lightOverlay = theme.dark ? 2 : 1 - const darkOverlay = theme.dark ? 1 : 2 - - const variables: string[] = [] - for (const [key, value] of Object.entries(theme.colors)) { - const rgb = parseColor(value) - variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}` + (rgb.a == null ? '' : `,${rgb.a}`)) - if (!key.startsWith('on-')) { - variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`) - } - } - - for (const [key, value] of Object.entries(theme.variables)) { - const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined - const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined - variables.push(`--${prefix}${key}: ${rgb ?? value}`) - } - - return variables -} - -function genVariation (name: string, color: Color, variations: VariationsOptions | false) { - const object: Record = {} - if (variations) { - for (const variation of (['lighten', 'darken'] as const)) { - const fn = variation === 'lighten' ? lighten : darken - for (const amount of createRange(variations[variation], 1)) { - object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount)) - } - } - } - return object -} - -function genVariations (colors: InternalThemeDefinition['colors'], variations: VariationsOptions | false) { - if (!variations) return {} - - let variationColors = {} - for (const name of variations.colors) { - const color = colors[name] - - if (!color) continue - - variationColors = { - ...variationColors, - ...genVariation(name, color, variations), - } - } - return variationColors -} - -function genOnColors (colors: InternalThemeDefinition['colors'], variables: InternalThemeDefinition['variables']) { - const onColors = {} as InternalThemeDefinition['colors'] - - for (const color of Object.keys(colors)) { - if (color.startsWith('on-') || colors[`on-${color}`]) continue - - const onColor = `on-${color}` as keyof OnColors - const colorVal = parseColor(colors[color]) - - onColors[onColor] = hasLightForeground(colorVal) - ? variables['theme-on-dark'] - : variables['theme-on-light'] - } - - return onColors -} - -function getScopedSelector (selector: string, scope?: string) { - if (!scope) return selector - - const scopeSelector = `:where(${scope})` - - return selector === ':root' ? scopeSelector : `${scopeSelector} ${selector}` -} - -function upsertStyles (id: string, cspNonce: string | undefined, styles: string) { - const styleEl = getOrCreateStyleElement(id, cspNonce) - - if (!styleEl) return - - styleEl.innerHTML = styles -} - -function getOrCreateStyleElement (id: string, cspNonce?: string) { - if (!IN_BROWSER) return null - - let style = document.getElementById(id) as HTMLStyleElement | null - - if (!style) { - style = document.createElement('style') - style.id = id - style.type = 'text/css' - - if (cspNonce) style.setAttribute('nonce', cspNonce) - - document.head.appendChild(style) - } - - return style -} - -// Composables -export function createTheme (options?: ThemeOptions): ThemeInstance & { install: (app: App) => void } { - const parsedOptions = parseThemeOptions(options) - const _name = shallowRef(parsedOptions.defaultTheme) - const themes = ref(parsedOptions.themes) - const systemName = shallowRef('light') - - const name = computed({ - get () { - return _name.value === 'system' ? systemName.value : _name.value - }, - set (val: string) { - _name.value = val - }, - }) - - const computedThemes = computed(() => { - const acc: Record = {} - for (const [name, original] of Object.entries(themes.value)) { - const defaultTheme = original.dark || name === 'dark' - ? themes.value.dark - : themes.value.light - - const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition - - const colors = { - ...merged.colors, - ...genVariations(merged.colors, parsedOptions.variations), - } - - acc[name] = { - ...merged, - colors: { - ...colors, - ...genOnColors(colors, merged.variables), - }, - } - } - return acc - }) - - const current = toRef(() => computedThemes.value[name.value]) - - const isSystem = toRef(() => _name.value === 'system') - - const styles = computed(() => { - const lines: string[] = [] - const scoped = parsedOptions.scoped ? parsedOptions.prefix : '' - - lines.push('@layer theme-base {\n') - - if (current.value?.dark) { - createCssClass(lines, ':root', ['color-scheme: dark'], parsedOptions.scope) - } - - createCssClass(lines, ':root', genCssVariables(current.value, parsedOptions.prefix), parsedOptions.scope) - - for (const [themeName, theme] of Object.entries(computedThemes.value)) { - createCssClass(lines, `.${parsedOptions.prefix}theme--${themeName}`, [ - `color-scheme: ${theme.dark ? 'dark' : 'normal'}`, - ...genCssVariables(theme, parsedOptions.prefix), - ], parsedOptions.scope) - } - - lines.push('}\n') - - if (parsedOptions.utilities) { - const bgLines: string[] = [] - const fgLines: string[] = [] - - const colors = new Set(Object.values(computedThemes.value).flatMap(theme => Object.keys(theme.colors))) - for (const key of colors) { - if (key.startsWith('on-')) { - createCssClass(fgLines, `.${key}`, [`color: rgb(var(--${parsedOptions.prefix}theme-${key}))`], parsedOptions.scope) - } else { - createCssClass(bgLines, `.${scoped}bg-${key}`, [ - `--${parsedOptions.prefix}theme-overlay-multiplier: var(--${parsedOptions.prefix}theme-${key}-overlay-multiplier)`, - `background-color: rgb(var(--${parsedOptions.prefix}theme-${key}))`, - `color: rgb(var(--${parsedOptions.prefix}theme-on-${key}))`, - ], parsedOptions.scope) - createCssClass(fgLines, `.${scoped}text-${key}`, [`color: rgb(var(--${parsedOptions.prefix}theme-${key}))`], parsedOptions.scope) - createCssClass(fgLines, `.${scoped}border-${key}`, [`--${parsedOptions.prefix}border-color: var(--${parsedOptions.prefix}theme-${key})`], parsedOptions.scope) - } - } - - lines.push( - '@layer theme-background {\n', - ...bgLines.map(v => ` ${v}`), - '}\n', - '@layer theme-foreground {\n', - ...fgLines.map(v => ` ${v}`), - '}\n', - ) - } - - return '@layer vuetify-utilities {\n' + lines.map(v => ` ${v}`).join('') + '\n}' - }) - - const themeClasses = toRef(() => parsedOptions.isDisabled ? undefined : `${parsedOptions.prefix}theme--${name.value}`) - const themeNames = toRef(() => Object.keys(computedThemes.value)) - - if (SUPPORTS_MATCH_MEDIA) { - const media = window.matchMedia('(prefers-color-scheme: dark)') - - function updateSystemName () { - systemName.value = media.matches ? 'dark' : 'light' - } - - updateSystemName() - - media.addEventListener('change', updateSystemName, { passive: true }) - - if (getCurrentScope()) { - onScopeDispose(() => { - media.removeEventListener('change', updateSystemName) - }) - } - } - - function install (app: App) { - if (parsedOptions.isDisabled) return - - const head = app._context.provides.usehead as HeadClient & VueHeadClient | undefined - if (head) { - function getHead () { - return { - style: [{ - textContent: styles.value, - id: parsedOptions.stylesheetId, - nonce: parsedOptions.cspNonce || false as never, - tagPosition: 'bodyOpen' as const, - }], - } - } - - if (head.push) { - const entry = head.push(getHead) - if (IN_BROWSER) { - watch(styles, () => { entry.patch(getHead) }) - } - } else { - if (IN_BROWSER) { - head.addHeadObjs(toRef(getHead)) - watchEffect(() => head.updateDOM()) - } else { - head.addHeadObjs(getHead()) - } - } - } else { - if (IN_BROWSER) { - watch(styles, updateStyles, { immediate: true }) - } else { - updateStyles() - } - - function updateStyles () { - upsertStyles(parsedOptions.stylesheetId, parsedOptions.cspNonce, styles.value) - } - } - } - - function change (themeName: string) { - if (themeName !== 'system' && !themeNames.value.includes(themeName)) { - consoleWarn(`Theme "${themeName}" not found on the Vuetify theme instance`) - return - } - - name.value = themeName - } - - function cycle (themeArray: string[] = themeNames.value) { - const currentIndex = themeArray.indexOf(name.value) - const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeArray.length - - change(themeArray[nextIndex]) - } - - function toggle (themeArray: [string, string] = ['light', 'dark']) { - cycle(themeArray) - } - - const globalName = new Proxy(name, { - get (target, prop) { - return Reflect.get(target, prop) - }, - set (target, prop, val) { - if (prop === 'value') { - deprecate(`theme.global.name.value = ${val}`, `theme.change('${val}')`) - } - return Reflect.set(target, prop, val) - }, - }) - - return { - install, - change, - cycle, - toggle, - isDisabled: parsedOptions.isDisabled, - isSystem, - name, - themes, - current, - computedThemes, - prefix: parsedOptions.prefix, - themeClasses, - styles, - global: { - name: globalName, - current, - }, - } -} - -export function provideTheme (props: { theme?: string }) { - getCurrentInstance('provideTheme') - - const theme = inject(ThemeSymbol, null) - - if (!theme) throw new Error('Could not find Vuetify theme injection') - - const name = toRef(() => props.theme ?? theme.name.value) - const current = toRef(() => theme.themes.value[name.value]) - - const themeClasses = toRef(() => theme.isDisabled ? undefined : `${theme.prefix}theme--${name.value}`) - - const newTheme: ThemeInstance = { - ...theme, - name, - current, - themeClasses, - } - - provide(ThemeSymbol, newTheme) - - return newTheme -} - -export function useTheme () { - getCurrentInstance('useTheme') - - const theme = inject(ThemeSymbol, null) - - if (!theme) throw new Error('Could not find Vuetify theme injection') - - return theme -} diff --git a/packages/vuetify/src/composables/theme/adapter.ts b/packages/vuetify/src/composables/theme/adapter.ts new file mode 100644 index 00000000000..18ebe4ac7e0 --- /dev/null +++ b/packages/vuetify/src/composables/theme/adapter.ts @@ -0,0 +1,218 @@ +// Composables +import { genCssVariables } from './colors' + +// Utilities +import { ThemeAdapter } from '@vuetify/v0/theme/adapters' +import { watch } from 'vue' +import { IN_BROWSER } from '@/util' + +// Types +import type { App, ComputedRef, Ref } from 'vue' +import type { InternalThemeDefinition } from './colors' + +interface AdapterSetupContext { + colors: ComputedRef>> + selectedId: Ref + isDark: Readonly> +} + +export interface VuetifyThemeAdapterOptions { + cspNonce?: string + scope?: string + stylesheetId?: string + prefix?: string + utilities?: boolean +} + +interface HeadEntry { + patch (input: () => Record): void +} + +interface HeadClient { + push (input: Record): HeadEntry +} + +/** + * Theme adapter that generates Vuetify-specific CSS with layered + * utility classes and RGB-decomposed custom properties. + * + * Receives fully-resolved themes (including on-* colors, variations, + * and variables) from the theme composable and formats them as CSS. + */ +export class VuetifyThemeAdapter extends ThemeAdapter { + cspNonce?: string + scope?: string + utilities: boolean + + private sheet?: CSSStyleSheet + private themes: Record = {} + + constructor (options: VuetifyThemeAdapterOptions = {}) { + super(options.prefix ?? 'v-') + this.cspNonce = options.cspNonce + this.scope = options.scope + this.utilities = options.utilities ?? true + this.stylesheetId = options.stylesheetId ?? 'vuetify-theme-stylesheet' + + // Vuetify always outputs RGB-decomposed values + this.rgb = true + } + + /** + * Provide the full theme definitions (with dark, colors, variables) + * so that generate() can produce complete Vuetify CSS. + */ + setThemes (themes: Record) { + this.themes = themes + } + + override generate ( + _colors: Record>, + isDark?: boolean, + ): string { + const lines: string[] = [] + + lines.push('@layer theme-base {\n') + + if (isDark) { + this.pushCssClass(lines, ':root', ['color-scheme: dark']) + } + + // Root CSS variables for current theme + const currentThemeName = isDark ? 'dark' : 'light' + const currentTheme = this.themes[currentThemeName] + if (currentTheme) { + this.pushCssClass(lines, ':root', genCssVariables(currentTheme, this.prefix)) + } + + // Per-theme classes + for (const [themeName, theme] of Object.entries(this.themes)) { + this.pushCssClass(lines, `.${this.prefix}theme--${themeName}`, [ + `color-scheme: ${theme.dark ? 'dark' : 'normal'}`, + ...genCssVariables(theme, this.prefix), + ]) + } + + lines.push('}\n') + + if (this.utilities) { + const bgLines: string[] = [] + const fgLines: string[] = [] + + const scoped = this.scope ? this.prefix : '' + const colors = new Set( + Object.values(this.themes).flatMap(theme => Object.keys(theme.colors)) + ) + + for (const key of colors) { + if (key.startsWith('on-')) { + this.pushCssClass(fgLines, `.${key}`, [ + `color: rgb(var(--${this.prefix}theme-${key}))`, + ]) + } else { + this.pushCssClass(bgLines, `.${scoped}bg-${key}`, [ + `--${this.prefix}theme-overlay-multiplier: var(--${this.prefix}theme-${key}-overlay-multiplier)`, + `background-color: rgb(var(--${this.prefix}theme-${key}))`, + `color: rgb(var(--${this.prefix}theme-on-${key}))`, + ]) + this.pushCssClass(fgLines, `.${scoped}text-${key}`, [ + `color: rgb(var(--${this.prefix}theme-${key}))`, + ]) + this.pushCssClass(fgLines, `.${scoped}border-${key}`, [ + `--${this.prefix}border-color: var(--${this.prefix}theme-${key})`, + ]) + } + } + + lines.push( + '@layer theme-background {\n', + ...bgLines.map(v => ` ${v}`), + '}\n', + '@layer theme-foreground {\n', + ...fgLines.map(v => ` ${v}`), + '}\n', + ) + } + + return '@layer vuetify-utilities {\n' + lines.map(v => ` ${v}`).join('') + '\n}' + } + + // @ts-expect-error Vue types mismatch between v0 and vuetify packages + setup ( + app: App, + context: T, + _target?: string | HTMLElement | null, + ): void { + const head = app._context?.provides?.usehead as HeadClient | undefined + + if (head) { + function getHead (adapter: VuetifyThemeAdapter) { + return { + style: [{ + textContent: adapter.generate(context.colors.value, context.isDark.value), + id: adapter.stylesheetId, + nonce: adapter.cspNonce || false as never, + tagPosition: 'bodyOpen' as const, + }], + } + } + + const entry = head.push(getHead(this)) + + if (IN_BROWSER) { + watch([context.colors, context.isDark], () => { + entry.patch(() => getHead(this)) + }) + } + } else if (IN_BROWSER) { + this.update(context.colors.value, context.isDark.value) + + watch([context.colors, context.isDark], ([colors, isDark]) => { + this.update( + colors as Record>, + isDark as boolean, + ) + }) + } else { + // SSR without head — generate but cannot inject + this.generate(context.colors.value, context.isDark.value) + } + } + + update ( + colors: Record>, + isDark?: boolean, + ): void { + if (!IN_BROWSER) return + + this.upsert(this.generate(colors, isDark)) + } + + private upsert (styles: string): void { + if (!IN_BROWSER) return + + if (!this.sheet) { + this.sheet = new CSSStyleSheet() + document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.sheet] + } + + this.sheet.replaceSync(styles) + } + + private pushCssClass (lines: string[], selector: string, content: string[]): void { + const scoped = this.getScopedSelector(selector) + lines.push( + `${scoped} {\n`, + ...content.map(line => ` ${line};\n`), + '}\n', + ) + } + + private getScopedSelector (selector: string): string { + if (!this.scope) return selector + + const scopeSelector = `:where(${this.scope})` + + return selector === ':root' ? scopeSelector : `${scopeSelector} ${selector}` + } +} diff --git a/packages/vuetify/src/composables/theme/colors.ts b/packages/vuetify/src/composables/theme/colors.ts new file mode 100644 index 00000000000..5f78f42b186 --- /dev/null +++ b/packages/vuetify/src/composables/theme/colors.ts @@ -0,0 +1,235 @@ +// Utilities +import { + createRange, + darken, + getLuma, + lighten, + mergeDeep, + parseColor, + RGBtoHex, +} from '@/util' + +// Types +import type { Color } from '@/util' + +type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T + +export interface Colors extends BaseColors, OnColors { + [key: string]: Color +} + +interface BaseColors { + background: Color + surface: Color + primary: Color + secondary: Color + success: Color + warning: Color + error: Color + info: Color +} + +interface OnColors { + 'on-background': Color + 'on-surface': Color + 'on-primary': Color + 'on-secondary': Color + 'on-success': Color + 'on-warning': Color + 'on-error': Color + 'on-info': Color +} + +export interface VariationsOptions { + colors: string[] + lighten: number + darken: number +} + +export interface InternalThemeDefinition { + dark: boolean + colors: Colors + variables: Record +} + +export type ThemeDefinition = DeepPartial + +export interface InternalThemeOptions { + cspNonce?: string + isDisabled: boolean + defaultTheme: 'light' | 'dark' | 'system' | string & {} + prefix: string + variations: false | VariationsOptions + themes: Record + stylesheetId: string + scope?: string + scoped: boolean + utilities: boolean +} + +export type ThemeOptions = false | { + cspNonce?: string + defaultTheme?: 'light' | 'dark' | 'system' | string & {} + variations?: false | VariationsOptions + themes?: Record + stylesheetId?: string + scope?: string + utilities?: boolean +} + +export function genDefaults () { + return { + defaultTheme: 'system', + prefix: 'v-', + variations: { colors: [], lighten: 0, darken: 0 }, + themes: { + light: { + dark: false, + colors: { + background: '#FFFFFF', + surface: '#FFFFFF', + 'surface-bright': '#FFFFFF', + 'surface-light': '#EEEEEE', + 'surface-variant': '#424242', + 'on-surface-variant': '#EEEEEE', + primary: '#1867C0', + 'primary-darken-1': '#1F5592', + secondary: '#48A9A6', + 'secondary-darken-1': '#018786', + error: '#B00020', + info: '#2196F3', + success: '#4CAF50', + warning: '#FB8C00', + }, + variables: { + 'border-color': '#000000', + 'border-opacity': 0.12, + 'shadow-color': '#000000', + 'high-emphasis-opacity': 0.87, + 'medium-emphasis-opacity': 0.60, + 'disabled-opacity': 0.38, + 'idle-opacity': 0.04, + 'hover-opacity': 0.04, + 'focus-opacity': 0.12, + 'selected-opacity': 0.08, + 'activated-opacity': 0.12, + 'pressed-opacity': 0.12, + 'dragged-opacity': 0.08, + 'theme-kbd': '#EEEEEE', + 'theme-on-kbd': '#000000', + 'theme-code': '#F5F5F5', + 'theme-on-code': '#000000', + 'theme-on-dark': '#FFF', + 'theme-on-light': '#000', + 'elevation-overlay-color': 'black', + 'elevation-overlay-opacity-step': '2%', + }, + }, + dark: { + dark: true, + colors: { + background: '#121212', + surface: '#212121', + 'surface-bright': '#ccbfd6', + 'surface-light': '#424242', + 'surface-variant': '#c8c8c8', + 'on-surface-variant': '#000000', + primary: '#2196F3', + 'primary-darken-1': '#277CC1', + secondary: '#54B6B2', + 'secondary-darken-1': '#48A9A6', + error: '#CF6679', + info: '#2196F3', + success: '#4CAF50', + warning: '#FB8C00', + }, + variables: { + 'border-color': '#FFFFFF', + 'border-opacity': 0.12, + 'shadow-color': '#000000', + 'high-emphasis-opacity': 1, + 'medium-emphasis-opacity': 0.70, + 'disabled-opacity': 0.50, + 'idle-opacity': 0.10, + 'hover-opacity': 0.04, + 'focus-opacity': 0.12, + 'selected-opacity': 0.08, + 'activated-opacity': 0.12, + 'pressed-opacity': 0.16, + 'dragged-opacity': 0.08, + 'theme-kbd': '#424242', + 'theme-on-kbd': '#FFFFFF', + 'theme-code': '#343434', + 'theme-on-code': '#CCCCCC', + 'theme-on-dark': '#FFF', + 'theme-on-light': '#000', + 'elevation-overlay-color': 'white', + 'elevation-overlay-opacity-step': '2%', + }, + }, + }, + stylesheetId: 'vuetify-theme-stylesheet', + scoped: false, + utilities: true, + } +} + +export function parseThemeOptions (options: ThemeOptions = genDefaults()): InternalThemeOptions { + const defaults = genDefaults() + + if (!options) return { ...defaults, isDisabled: true } as any + + return mergeDeep(defaults, options) as InternalThemeOptions +} + +export function genCssVariables (theme: InternalThemeDefinition, prefix: string) { + const lightOverlay = theme.dark ? 2 : 1 + const darkOverlay = theme.dark ? 1 : 2 + + const variables: string[] = [] + for (const [key, value] of Object.entries(theme.colors)) { + const rgb = parseColor(value) + variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}` + (rgb.a == null ? '' : `,${rgb.a}`)) + if (!key.startsWith('on-')) { + variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`) + } + } + + for (const [key, value] of Object.entries(theme.variables)) { + const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined + const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined + variables.push(`--${prefix}${key}: ${rgb ?? value}`) + } + + return variables +} + +function genVariation (name: string, color: Color, variations: VariationsOptions | false) { + const object: Record = {} + if (variations) { + for (const variation of (['lighten', 'darken'] as const)) { + const fn = variation === 'lighten' ? lighten : darken + for (const amount of createRange(variations[variation], 1)) { + object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount)) + } + } + } + return object +} + +export function genVariations (colors: InternalThemeDefinition['colors'], variations: VariationsOptions | false) { + if (!variations) return {} + + let variationColors = {} + for (const name of variations.colors) { + const color = colors[name] + + if (!color) continue + + variationColors = { + ...variationColors, + ...genVariation(name, color, variations), + } + } + return variationColors +} diff --git a/packages/vuetify/src/composables/theme/index.ts b/packages/vuetify/src/composables/theme/index.ts new file mode 100644 index 00000000000..642ccc39ced --- /dev/null +++ b/packages/vuetify/src/composables/theme/index.ts @@ -0,0 +1,235 @@ +// Utilities +import { + createTheme as createV0Theme, + usePrefersDark, +} from '@vuetify/v0' +import { + computed, + inject, + provide, + ref, + shallowRef, + toRef, + watch, +} from 'vue' +import { VuetifyThemeAdapter } from './adapter' +import { + genVariations, + parseThemeOptions, +} from './colors' +import { + consoleWarn, + getCurrentInstance, + mergeDeep, + propsFactory, +} from '@/util' + +// Types +import type { App, DeepReadonly, InjectionKey, Ref } from 'vue' +import type { Colors, InternalThemeDefinition, ThemeOptions } from './colors' + +export type { Colors, ThemeDefinition, ThemeOptions } from './colors' + +export interface ThemeInstance { + change: (themeName: string) => void + cycle: (themeArray?: string[]) => void + toggle: (themeArray?: [string, string]) => void + + readonly themes: Ref> + + readonly name: Readonly> + readonly current: DeepReadonly> + readonly computedThemes: DeepReadonly>> + readonly prefix: string + + readonly themeClasses: Readonly> +} + +export const ThemeSymbol: InjectionKey = Symbol.for('vuetify:theme') + +export const makeThemeProps = propsFactory({ + theme: String, +}, 'theme') + +// Composables +export function createTheme (options?: ThemeOptions): ThemeInstance & { install: (app: App) => void } { + const parsedOptions = parseThemeOptions(options) + const themes = ref(parsedOptions.themes) + + // Build processed themes with variations + on-colors for v0 registration + function buildV0Themes () { + const processed: Record }> = {} + for (const [themeName, original] of Object.entries(themes.value)) { + const defaultTheme = original.dark || themeName === 'dark' + ? themes.value.dark + : themes.value.light + + const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition + const colors = { + ...merged.colors, + ...genVariations(merged.colors, parsedOptions.variations), + } + + processed[themeName] = { + dark: merged.dark, + colors: colors as Record, + } + } + return processed + } + + // Resolve default theme — v0 doesn't understand 'system' + const isSystemDefault = parsedOptions.defaultTheme === 'system' + const { matches: prefersDark } = usePrefersDark() + const resolvedDefault = isSystemDefault + ? (prefersDark.value ? 'dark' : 'light') + : parsedOptions.defaultTheme + + // Create v0 theme instance + const v0Theme = createV0Theme({ + reactive: true, + foreground: true, + default: resolvedDefault, + themes: buildV0Themes(), + }) + + // Bridge v0's selectedId to Vuetify's name ref + const _name = shallowRef(parsedOptions.defaultTheme) + + const name = computed({ + get () { + if (_name.value === 'system') { + return prefersDark.value ? 'dark' : 'light' + } + return String(v0Theme.selectedId.value ?? resolvedDefault) + }, + set (val: string) { + _name.value = val + if (val !== 'system') { + v0Theme.select(val) + } + }, + }) + + // When system preference changes and we're in system mode, update v0 selection + watch(prefersDark, dark => { + if (_name.value === 'system') { + v0Theme.select(dark ? 'dark' : 'light') + } + }) + + const computedThemes = computed(() => { + const acc: Record = {} + const v0Colors = v0Theme.colors.value + + for (const [themeName, original] of Object.entries(themes.value)) { + const defaultTheme = original.dark || themeName === 'dark' + ? themes.value.dark + : themes.value.light + + const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition + + acc[themeName] = { + dark: merged.dark, + variables: merged.variables, + colors: (v0Colors[themeName] ?? merged.colors) as Colors, + } + } + return acc + }) + + const current = toRef(() => computedThemes.value[name.value]) + + const themeClasses = toRef(() => parsedOptions.isDisabled ? undefined : `${parsedOptions.prefix}theme--${name.value}`) + const themeNames = toRef(() => Object.keys(computedThemes.value)) + + const adapter = new VuetifyThemeAdapter({ + cspNonce: parsedOptions.cspNonce, + scope: parsedOptions.scope, + stylesheetId: parsedOptions.stylesheetId, + prefix: parsedOptions.prefix, + utilities: parsedOptions.utilities, + }) + + function install (app: App) { + if (parsedOptions.isDisabled) return + + adapter.setThemes(computedThemes.value) + adapter.setup(app, { + colors: v0Theme.colors as any, + selectedId: v0Theme.selectedId as any, + isDark: v0Theme.isDark as any, + }) + + watch(computedThemes, val => { + adapter.setThemes(val) + }) + } + + function change (themeName: string) { + if (themeName !== 'system' && !themeNames.value.includes(themeName)) { + consoleWarn(`Theme "${themeName}" not found on the Vuetify theme instance`) + return + } + + name.value = themeName + } + + function cycle (themeArray: string[] = themeNames.value) { + const currentIndex = themeArray.indexOf(name.value) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeArray.length + + change(themeArray[nextIndex]) + } + + function toggle (themeArray: [string, string] = ['light', 'dark']) { + cycle(themeArray) + } + + return { + install, + change, + cycle, + toggle, + name, + themes, + current, + computedThemes, + prefix: parsedOptions.prefix, + themeClasses, + } +} + +export function provideTheme (props: { theme?: string }) { + getCurrentInstance('provideTheme') + + const theme = inject(ThemeSymbol, null) + + if (!theme) throw new Error('Could not find Vuetify theme injection') + + const name = toRef(() => props.theme ?? theme.name.value) + const current = toRef(() => theme.themes.value[name.value]) + + const themeClasses = toRef(() => `${theme.prefix}theme--${name.value}`) + + const newTheme: ThemeInstance = { + ...theme, + name, + current, + themeClasses, + } + + provide(ThemeSymbol, newTheme) + + return newTheme +} + +export function useTheme () { + getCurrentInstance('useTheme') + + const theme = inject(ThemeSymbol, null) + + if (!theme) throw new Error('Could not find Vuetify theme injection') + + return theme +}