DEV Community

moshmage
moshmage

Posted on

Advanced VueMaterial Theming

Why? Well, "Coming soon..." isn't soon enough.

Disclaimer

This was the solution I had to come up on the spot. It serves its propose and can certainly be improved. It's based on old-time notions of "provide the minimum, download what you need".

VueMaterial and Themes

It ain't easy, but I'll give you a summary. VueMaterial "native" theming is enough if all you want is to change some colors on the default theme and you should read their configurations documents if all you want is that.

Summarizing, you use the scss to provide some modifications to the "default" theme provided by vue-material which is then imported by your main file via your equivalent of

import 'vue-material/dist/vue-material.min.css'
import 'vue-material/dist/theme/default.css'

These are then caught by the corresponding webpack loaders and then spat out onto files and retrieved when needed.

Intermedium Theming

But what if you want to provide the same functionality offered on vue-material website where you can change your theme on the fly?

Well, you'd need to add a new theme file, and then import it again on your main file, which would then represented on your final index.html. This is all cool until the following hits you: Each vue-material theme we produce has all the vue-material theming attached, courtesy of these two imports

@import "~vue-material/dist/theme/engine"; // Import the theme engine
@import "~vue-material/dist/theme/all"; // Apply the theme

Since you'll be repeating this throughout your themes, your site will get duplicated css that might, or probably will never, be used.

Advanced Theming

How do we solve this? with a couple of preperation steps and a Singleton acting as a bridge between your application and the loading of new themes.

What we will be doing

We will need to hook on two life-cycles of a vuejs application: its serve and its build, and will act before and after, accordignly, with some actions that will extract the themes into the same folder that vuejs will output the website.

What you'll need

Issue the following so we deal with all dependencies in one go,

npm i -D glob clean-webpack-plugin remove-files-webpack-plugin optimize-css-assets-webpack-plugin cssnano file-loader extract-loader css-loader sass-loader node-sass webpack

Themes structure

We will start by changing the main file and remove the inclusion of import 'vue-material/dist/theme/default.css' as we will have this be loaded later when the application starts

Following that, we will create a folder for our themes and a main one with some variables:

  • create /themes/folder on the same level as /src/
  • add a new /main/folder for the main theme
  • and variables.scss and theme.scss

Populate variables.scss with

$theme-name: 'main' !default;
$primary-color: pink !default;
$secondary-color: blue !default;
$danger-color: red !default;

and theme.scss with

@import "~vue-material/dist/theme/engine";
@import "variables";

@include md-register-theme(
                $theme-name,
                (
                        primary: $primary-color,
                        accent: $secondary-color,
                        theme: light,
                        red: $danger-color
                )
)

:root {
  --md-theme-#{$theme-name}-custom-variables: pink;
}

.md-theme-#{$theme-name} {
  #app {
    font-family: monospacef;
  }

  /* your css customizations here, I'd advise you to make barrel-imports */
  @import "./import-barrel";
}

@import "~vue-material/dist/theme/all;

Creating new themes

All we really need to create a new theme is override the values in /themes/main/variables.scss with the ones from the new theme,

create a new folder under /themes/with the name of the theme, /theme/red-on-black/, and create a theme.scss inside with

$theme-name: 'red-on-black';
$primary-color: 'red';
$secondary-color: 'black';
$danger-color: 'yellow';

@import '../main/theme.scss';

This will essentially make a copy of main theme with new values, since we provided !default on each value under /themes/main/variables.scss these will not override the variables provided by /themes/red-on-black/theme.scss

"A png is worth 10k chars"

Building the themes into CSS

We have themes that make use of vue-material, but these themes in no way shape or form interact with our website yet. To achieve this, we need some webpack magic.

We'll create a webpack configuration that will process our theme scss files and output them as css ready to be loaded, by taking advantage of the public folder we normaly use to provide custom index.html implementations, or dist if we're building:

// theming.webpack.config.js
const glob = require('glob');
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const RemovePlugin = require('remove-files-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');

const name = (f) => `${f.match(/themes\/(.+)\/theme\.\w+$/)[1]}.css`;
const output = ({mode}) => mode === 'development' ? 'public' : 'dist';

const config = env => ({
  entry: glob.sync('./themes/**/theme.scss').map(f => f),
  mode: env.mode,
  output: {
    filename: 'delete.me',
    path: path.join(__dirname, output(env), 'themes')
  },
  plugins: [
    new CleanWebpackPlugin(),
    new RemovePlugin({
      after: {include: [path.join(__dirname, output(env), 'themes', 'delete.me')], trash: false}
    }),
    new OptimizeCssAssetsPlugin({
      cssProcessor: require('cssnano'),
      cssProcessorPluginOptions: {
        preset: ['default', { discardComments: { removeAll: true } }],
      },
      canPrint: true
    })
  ],
  module: {
    rules: [
      {
        test: /themes\/.+\/theme.scss$/,
        use: [
          {loader: 'file-loader', options: {name}},
          {loader: 'extract-loader'},
          {loader: 'css-loader?-url'},
          {loader: 'sass-loader'},
        ]
      }
    ]
  },

});

module.exports = config;

and then create two new scripts in your package.json and two more aliases,

{
    "theme:serve": "webpack --config theming.webpack.conf.js --env.mode='development' --watch & echo 'Theme Service Started!'",
    "theme:build": "webpack --config theming.webpack.conf.js --env.mode='production'",
    "postbuild": "npm run theme:build",
    "preserve": "npm run theme:serve"
}
Couple of points:
  • theme:serve and theme:build essentially call webpack with different --env.mode values, so we can output to the correct places.
  • preserve and postbuild are used as alias so you don't have to chain any commands.
  • We're taking advantage of &, for serve, (which will execute both commands concurrently) so we can have the theme reload the files on public when we make changes to the files in /themes/which are then caught by vuejs and the application reloads

Theme Service

The theme files are processed and outputed on the correct folders, we can access them via /themes/[name].css but we still haven't load it. for that we will need a singleton,

// theme.js
const makeAttr = (attr, value) => ({attr, value});
const loadedThemes = [];

export class Theme {

  loadTheme(name = '') {
    if (!name) return Promise.resolve(false);
    if (document.querySelector(`#vue-material-theme-${name}`)) return Promise.resolve(true);

    return new Promise(resolve => {
      const themeElement = document.createElement('link');

      themeElement.onload = () => {
        loadedThemes.push(name);
        resolve(true)
      };

      themeElement.onerror = () => {
        const ele = document.getElementById(`vue-material-theme-${name}`);
        if (ele) ele.parentNode?.removeChild(ele);
        resolve(false);
      };

      [
        makeAttr('rel', 'stylesheet'),
        makeAttr('id', `vue-material-theme-${name}`),
        makeAttr('type', 'text/css'),
        makeAttr('href', `/themes/${name}.css`),
      ].forEach(({attr, value}) => themeElement.setAttribute(attr, value));

      document.getElementsByTagName('head').item(0)?.appendChild(themeElement);
    });
  }
}

export const ThemeService = new Theme();

With the ThemeService singleton we're almost ready to make magic happen: All it's left to do is simply call ThemeService.loadTheme('main') when our application starts and tell VueMaterial to use main (even if it doesn't know what main is) as a theme:

on your main file,

Vue.use(VueMaterial);
Vue.material.theming.theme = 'main';

and in your App.vue file, just add a new method that waits for the resolution of ThemeService.loadTheme():

// App.vue
// ...
async changeTheme(name = 'main') {
    const loaded = await ThemeService.loadTheme(name);
    if (loaded) this.$material.theming.theme = name;
    // if !loaded, something happened. change Theme class at will to debug stuff
}

Don't forget to call this function on the mounted() hook as well!

Final thoughts

Why are we running parallel watches and dont hook on vuejs?

VueJS isn't much permissive in its entry files, even with webpackChain we would have to accomudate for too much loaders, uses and rules. Since we never actually need the scss that vuejs parses since our scss will always live outside the src file, we can ignore it altogether. Granted, it's a bit ugly - shout me up if you know a better solution!

Top comments (0)