theme.mjs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. // Utilities
  2. import { computed, inject, provide, ref, watch, watchEffect } from 'vue';
  3. import { createRange, darken, getCurrentInstance, getLuma, IN_BROWSER, lighten, mergeDeep, parseColor, propsFactory, RGBtoHex } from "../util/index.mjs";
  4. import { APCAcontrast } from "../util/color/APCA.mjs"; // Types
  5. export const ThemeSymbol = Symbol.for('vuetify:theme');
  6. export const makeThemeProps = propsFactory({
  7. theme: String
  8. }, 'theme');
  9. const defaultThemeOptions = {
  10. defaultTheme: 'light',
  11. variations: {
  12. colors: [],
  13. lighten: 0,
  14. darken: 0
  15. },
  16. themes: {
  17. light: {
  18. dark: false,
  19. colors: {
  20. background: '#FFFFFF',
  21. surface: '#FFFFFF',
  22. 'surface-variant': '#424242',
  23. 'on-surface-variant': '#EEEEEE',
  24. primary: '#6200EE',
  25. 'primary-darken-1': '#3700B3',
  26. secondary: '#03DAC6',
  27. 'secondary-darken-1': '#018786',
  28. error: '#B00020',
  29. info: '#2196F3',
  30. success: '#4CAF50',
  31. warning: '#FB8C00'
  32. },
  33. variables: {
  34. 'border-color': '#000000',
  35. 'border-opacity': 0.12,
  36. 'high-emphasis-opacity': 0.87,
  37. 'medium-emphasis-opacity': 0.60,
  38. 'disabled-opacity': 0.38,
  39. 'idle-opacity': 0.04,
  40. 'hover-opacity': 0.04,
  41. 'focus-opacity': 0.12,
  42. 'selected-opacity': 0.08,
  43. 'activated-opacity': 0.12,
  44. 'pressed-opacity': 0.12,
  45. 'dragged-opacity': 0.08,
  46. 'theme-kbd': '#212529',
  47. 'theme-on-kbd': '#FFFFFF',
  48. 'theme-code': '#F5F5F5',
  49. 'theme-on-code': '#000000'
  50. }
  51. },
  52. dark: {
  53. dark: true,
  54. colors: {
  55. background: '#121212',
  56. surface: '#212121',
  57. 'surface-variant': '#BDBDBD',
  58. 'on-surface-variant': '#424242',
  59. primary: '#BB86FC',
  60. 'primary-darken-1': '#3700B3',
  61. secondary: '#03DAC5',
  62. 'secondary-darken-1': '#03DAC5',
  63. error: '#CF6679',
  64. info: '#2196F3',
  65. success: '#4CAF50',
  66. warning: '#FB8C00'
  67. },
  68. variables: {
  69. 'border-color': '#FFFFFF',
  70. 'border-opacity': 0.12,
  71. 'high-emphasis-opacity': 1,
  72. 'medium-emphasis-opacity': 0.70,
  73. 'disabled-opacity': 0.50,
  74. 'idle-opacity': 0.10,
  75. 'hover-opacity': 0.04,
  76. 'focus-opacity': 0.12,
  77. 'selected-opacity': 0.08,
  78. 'activated-opacity': 0.12,
  79. 'pressed-opacity': 0.16,
  80. 'dragged-opacity': 0.08,
  81. 'theme-kbd': '#212529',
  82. 'theme-on-kbd': '#FFFFFF',
  83. 'theme-code': '#343434',
  84. 'theme-on-code': '#CCCCCC'
  85. }
  86. }
  87. }
  88. };
  89. function parseThemeOptions() {
  90. let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : defaultThemeOptions;
  91. if (!options) return {
  92. ...defaultThemeOptions,
  93. isDisabled: true
  94. };
  95. const themes = {};
  96. for (const [key, theme] of Object.entries(options.themes ?? {})) {
  97. const defaultTheme = theme.dark || key === 'dark' ? defaultThemeOptions.themes?.dark : defaultThemeOptions.themes?.light;
  98. themes[key] = mergeDeep(defaultTheme, theme);
  99. }
  100. return mergeDeep(defaultThemeOptions, {
  101. ...options,
  102. themes
  103. });
  104. }
  105. // Composables
  106. export function createTheme(options) {
  107. const parsedOptions = parseThemeOptions(options);
  108. const name = ref(parsedOptions.defaultTheme);
  109. const themes = ref(parsedOptions.themes);
  110. const computedThemes = computed(() => {
  111. const acc = {};
  112. for (const [name, original] of Object.entries(themes.value)) {
  113. const theme = acc[name] = {
  114. ...original,
  115. colors: {
  116. ...original.colors
  117. }
  118. };
  119. if (parsedOptions.variations) {
  120. for (const name of parsedOptions.variations.colors) {
  121. const color = theme.colors[name];
  122. if (!color) continue;
  123. for (const variation of ['lighten', 'darken']) {
  124. const fn = variation === 'lighten' ? lighten : darken;
  125. for (const amount of createRange(parsedOptions.variations[variation], 1)) {
  126. theme.colors[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount));
  127. }
  128. }
  129. }
  130. }
  131. for (const color of Object.keys(theme.colors)) {
  132. if (/^on-[a-z]/.test(color) || theme.colors[`on-${color}`]) continue;
  133. const onColor = `on-${color}`;
  134. const colorVal = parseColor(theme.colors[color]);
  135. const blackContrast = Math.abs(APCAcontrast(parseColor(0), colorVal));
  136. const whiteContrast = Math.abs(APCAcontrast(parseColor(0xffffff), colorVal));
  137. // TODO: warn about poor color selections
  138. // const contrastAsText = Math.abs(APCAcontrast(colorVal, colorToInt(theme.colors.background)))
  139. // const minContrast = Math.max(blackContrast, whiteContrast)
  140. // if (minContrast < 60) {
  141. // consoleInfo(`${key} theme color ${color} has poor contrast (${minContrast.toFixed()}%)`)
  142. // } else if (contrastAsText < 60 && !['background', 'surface'].includes(color)) {
  143. // consoleInfo(`${key} theme color ${color} has poor contrast as text (${contrastAsText.toFixed()}%)`)
  144. // }
  145. // Prefer white text if both have an acceptable contrast ratio
  146. theme.colors[onColor] = whiteContrast > Math.min(blackContrast, 50) ? '#fff' : '#000';
  147. }
  148. }
  149. return acc;
  150. });
  151. const current = computed(() => computedThemes.value[name.value]);
  152. const styles = computed(() => {
  153. const lines = [];
  154. if (current.value.dark) {
  155. createCssClass(lines, ':root', ['color-scheme: dark']);
  156. }
  157. createCssClass(lines, ':root', genCssVariables(current.value));
  158. for (const [themeName, theme] of Object.entries(computedThemes.value)) {
  159. createCssClass(lines, `.v-theme--${themeName}`, [`color-scheme: ${theme.dark ? 'dark' : 'normal'}`, ...genCssVariables(theme)]);
  160. }
  161. const bgLines = [];
  162. const fgLines = [];
  163. const colors = new Set(Object.values(computedThemes.value).flatMap(theme => Object.keys(theme.colors)));
  164. for (const key of colors) {
  165. if (/^on-[a-z]/.test(key)) {
  166. createCssClass(fgLines, `.${key}`, [`color: rgb(var(--v-theme-${key})) !important`]);
  167. } else {
  168. createCssClass(bgLines, `.bg-${key}`, [`--v-theme-overlay-multiplier: var(--v-theme-${key}-overlay-multiplier)`, `background-color: rgb(var(--v-theme-${key})) !important`, `color: rgb(var(--v-theme-on-${key})) !important`]);
  169. createCssClass(fgLines, `.text-${key}`, [`color: rgb(var(--v-theme-${key})) !important`]);
  170. createCssClass(fgLines, `.border-${key}`, [`--v-border-color: var(--v-theme-${key})`]);
  171. }
  172. }
  173. lines.push(...bgLines, ...fgLines);
  174. return lines.map((str, i) => i === 0 ? str : ` ${str}`).join('');
  175. });
  176. function getHead() {
  177. return {
  178. style: [{
  179. children: styles.value,
  180. id: 'vuetify-theme-stylesheet',
  181. nonce: parsedOptions.cspNonce || false
  182. }]
  183. };
  184. }
  185. function install(app) {
  186. if (parsedOptions.isDisabled) return;
  187. const head = app._context.provides.usehead;
  188. if (head) {
  189. if (head.push) {
  190. const entry = head.push(getHead);
  191. if (IN_BROWSER) {
  192. watch(styles, () => {
  193. entry.patch(getHead);
  194. });
  195. }
  196. } else {
  197. if (IN_BROWSER) {
  198. head.addHeadObjs(computed(getHead));
  199. watchEffect(() => head.updateDOM());
  200. } else {
  201. head.addHeadObjs(getHead());
  202. }
  203. }
  204. } else {
  205. let styleEl = IN_BROWSER ? document.getElementById('vuetify-theme-stylesheet') : null;
  206. if (IN_BROWSER) {
  207. watch(styles, updateStyles, {
  208. immediate: true
  209. });
  210. } else {
  211. updateStyles();
  212. }
  213. function updateStyles() {
  214. if (typeof document !== 'undefined' && !styleEl) {
  215. const el = document.createElement('style');
  216. el.type = 'text/css';
  217. el.id = 'vuetify-theme-stylesheet';
  218. if (parsedOptions.cspNonce) el.setAttribute('nonce', parsedOptions.cspNonce);
  219. styleEl = el;
  220. document.head.appendChild(styleEl);
  221. }
  222. if (styleEl) styleEl.innerHTML = styles.value;
  223. }
  224. }
  225. }
  226. const themeClasses = computed(() => parsedOptions.isDisabled ? undefined : `v-theme--${name.value}`);
  227. return {
  228. install,
  229. isDisabled: parsedOptions.isDisabled,
  230. name,
  231. themes,
  232. current,
  233. computedThemes,
  234. themeClasses,
  235. styles,
  236. global: {
  237. name,
  238. current
  239. }
  240. };
  241. }
  242. export function provideTheme(props) {
  243. getCurrentInstance('provideTheme');
  244. const theme = inject(ThemeSymbol, null);
  245. if (!theme) throw new Error('Could not find Vuetify theme injection');
  246. const name = computed(() => {
  247. return props.theme ?? theme?.name.value;
  248. });
  249. const themeClasses = computed(() => theme.isDisabled ? undefined : `v-theme--${name.value}`);
  250. const newTheme = {
  251. ...theme,
  252. name,
  253. themeClasses
  254. };
  255. provide(ThemeSymbol, newTheme);
  256. return newTheme;
  257. }
  258. export function useTheme() {
  259. getCurrentInstance('useTheme');
  260. const theme = inject(ThemeSymbol, null);
  261. if (!theme) throw new Error('Could not find Vuetify theme injection');
  262. return theme;
  263. }
  264. function createCssClass(lines, selector, content) {
  265. lines.push(`${selector} {\n`, ...content.map(line => ` ${line};\n`), '}\n');
  266. }
  267. function genCssVariables(theme) {
  268. const lightOverlay = theme.dark ? 2 : 1;
  269. const darkOverlay = theme.dark ? 1 : 2;
  270. const variables = [];
  271. for (const [key, value] of Object.entries(theme.colors)) {
  272. const rgb = parseColor(value);
  273. variables.push(`--v-theme-${key}: ${rgb.r},${rgb.g},${rgb.b}`);
  274. if (!key.startsWith('on-')) {
  275. variables.push(`--v-theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`);
  276. }
  277. }
  278. for (const [key, value] of Object.entries(theme.variables)) {
  279. const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined;
  280. const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined;
  281. variables.push(`--v-${key}: ${rgb ?? value}`);
  282. }
  283. return variables;
  284. }
  285. //# sourceMappingURL=theme.mjs.map