I have a VueJS app that will come with many different themes (at least 20 or so). Each theme stylesheet not only changes things like color and font size, but also the position and layout of some elements as well.
I want the user to be able to switch between these themes dynamically. So, at runtime, the user will be able to open an Options menu and select from a dropdown.
What is the cleanest way to have many dynamic user-selectable themes in VueJS?
I've thought of a couple of ways, such as:
Dynamically inserting a <link> or <style> tag. While this might work, I don't really see it as particularly "clean", and if I'm loading from AJAX, then oftentimes I'll see a FOUC.
Simply changing the Vue class bindings through a computed property. Something like having a if-else chain for every supported theme in every component. I don't particularly like this solution, as then every component I make will need to be updated every time I add a new theme later on.
In React, I think there's a plugin or something that has a <ThemeProvider> component, where adding a theme is as simple as wrapping it, i.e. <ThemeProvider theme={themeProp}><MyComponent></ThemeProvider>, and all styles in that theme will apply to that component and all child components.
Does VueJS have something similar, or is there a way to implement it?
I will admit I had some fun with this one. This solution does not depend on Vue, but it can easily by used by Vue. Here we go!
My goal is to create a "particularly clean" dynamic insertion of <link> stylesheets which should not result in a FOUC.
I created a class (technically, it's a constructor function, but you know what I mean) called ThemeHelper, which works like this:
myThemeHelper.add(themeName, href) will preload a stylesheet from href (a URL) with stylesheet.disabled = true, and give it a name (just for keeping track of it). This returns a Promise that resolves to a CSSStyleSheet when the stylesheet's onload is called.
myThemeHelper.theme = "<theme name>"(setter) select a theme to apply. The previous theme is disabled, and the given theme is enabled. The switch happens quickly because the stylesheet has already been pre-loaded by .add.
myThemeHelper.theme (getter) returns the current theme name.
The class itself is 33 lines. I made a snippet that switches between some Bootswatch themes, since those CSS files are pretty large (100Kb+).
const ThemeHelper = function() {
const preloadTheme = (href) => {
let link = document.createElement('link');
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
return new Promise((resolve, reject) => {
link.onload = e => {
const sheet = e.target.sheet;
sheet.disabled = true;
resolve(sheet);
};
link.onerror = reject;
});
};
const selectTheme = (themes, name) => {
if (name && !themes[name]) {
throw new Error(`"${name}" has not been defined as a theme.`);
}
Object.keys(themes).forEach(n => themes[n].disabled = (n !== name));
}
let themes = {};
return {
add(name, href) { return preloadTheme(href).then(s => themes[name] = s) },
set theme(name) { selectTheme(themes, name) },
get theme() { return Object.keys(themes).find(n => !themes[n].disabled) }
};
};
const themes = {
flatly: "https://bootswatch.com/4/flatly/bootstrap.min.css",
materia: "https://bootswatch.com/4/materia/bootstrap.min.css",
solar: "https://bootswatch.com/4/solar/bootstrap.min.css"
};
const themeHelper = new ThemeHelper();
let added = Object.keys(themes).map(n => themeHelper.add(n, themes[n]));
Promise.all(added).then(sheets => {
console.log(`${sheets.length} themes loaded`);
themeHelper.theme = "materia";
});
<h3>Click a button to select a theme</h3>
<button
class="btn btn-primary"
onclick="themeHelper.theme='materia'">Paper theme
</button>
<button
class="btn btn-primary"
onclick="themeHelper.theme='flatly'">Flatly theme
</button>
<button
class="btn btn-primary"
onclick="themeHelper.theme='solar'">Solar theme
</button>
It is not hard to tell that I'm all about ES6 (and maybe I overused const just a bit :)
As far as Vue goes, you could make a component that wraps a <select>:
const ThemeHelper = function() {
const preloadTheme = (href) => {
let link = document.createElement('link');
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
return new Promise((resolve, reject) => {
link.onload = e => {
const sheet = e.target.sheet;
sheet.disabled = true;
resolve(sheet);
};
link.onerror = reject;
});
};
const selectTheme = (themes, name) => {
if (name && !themes[name]) {
throw new Error(`"${name}" has not been defined as a theme.`);
}
Object.keys(themes).forEach(n => themes[n].disabled = (n !== name));
}
let themes = {};
return {
add(name, href) { return preloadTheme(href).then(s => themes[name] = s) },
set theme(name) { selectTheme(themes, name) },
get theme() { return Object.keys(themes).find(n => !themes[n].disabled) }
};
};
let app = new Vue({
el: '#app',
data() {
return {
themes: {
flatly: "https://bootswatch.com/4/flatly/bootstrap.min.css",
materia: "https://bootswatch.com/4/materia/bootstrap.min.css",
solar: "https://bootswatch.com/4/solar/bootstrap.min.css"
},
themeHelper: new ThemeHelper(),
loading: true,
}
},
created() {
// add/load themes
let added = Object.keys(this.themes).map(name => {
return this.themeHelper.add(name, this.themes[name]);
});
Promise.all(added).then(sheets => {
console.log(`${sheets.length} themes loaded`);
this.loading = false;
this.themeHelper.theme = "flatly";
});
}
});
<script src="https://unpkg.com/vue#2.5.2/dist/vue.js"></script>
<div id="app">
<p v-if="loading">loading...</p>
<select v-model="themeHelper.theme">
<option v-for="(href, name) of themes" v-bind:value="name">
{{ name }}
</option>
</select>
<span>Selected: {{ themeHelper.theme }}</span>
</div>
<hr>
<h3>Select a theme above</h3>
<button class="btn btn-primary">A Button</button>
I hope this is as useful to you as it was fun for me!
Today I found possibly the simplest way to solve this and it even works with SCSS (no need to have separate CSS for each theme, which is important if your themes are based on one library and you only want to define the changes), but it needs
Make an .scss/.css file for each theme
Make these available somewhere in the src folder, src/bootstrap-themes/dark.scss for example
Import the .scss with a condition in the App.vue, in the created:, for example
if (Vue.$cookies.get('darkmode') === 'true') {
import('../bootstrap-themes/dark.scss');
this.nightmode = true;
} else {
import('../bootstrap-themes/light.scss');
this.nightmode = false;
}
When the user lands on the page, I read the cookies and see if they left nightmode enabled when they left last time and load the correct scss
When they use the switch to change the theme, this method is called, which saves the cookie and reloads the page, which will then read the cookie and load the correct scss
setTheme(nightmode) {
this.$cookies.set("darkmode", nightmode, "7d")
this.$router.go()
}
One very simple and working approach: Just change the css class of your body dynamically.
how about this,
https://www.mynotepaper.com/create-multiple-themes-in-vuejs
and this,
https://vuedose.tips/tips/theming-using-custom-properties-in-vuejs-components/
I think that will give you a basic idea for your project.
first of all I would like to thank ContinuousLoad for its inspiring code snippet.
It helped me a lot to make my own theme chooser.
I just wanted to give some feedback and share my changes to original code, specially in function preloadTheme. The biggest change was to remove the onload() event listener after initial load, because it would re-run each time you change the link.disabled value, at least under Firefox.
Hope it helps :)
const ThemeHelper = function() {
const preloadTheme = href => {
let link = document.createElement('link');
link.rel = 'stylesheet';
link.disabled = false;
link.href = href;
return new Promise((resolve, reject) => {
link.onload = function() {
// Remove the onload() event listener after initial load, because some
// browsers (like Firefox) could call onload() later again when changing
// the link.disabled value.
link.onload = null;
link.disabled = true;
resolve(link);
};
link.onerror = event => {
link.onerror = null;
reject(event);
};
document.head.appendChild(link);
});
};
const selectTheme = (themes, name) => {
if (name && !themes[name]) {
throw new Error(`"${name}" has not been defined as a theme.`);
}
Object.keys(themes).forEach(n => {
if (n !== name && !themes[n].disabled) themes[n].disabled = true;
});
if (themes[name].disabled) themes[name].disabled = false;
};
let themes = {};
return {
add(name, href) {
return preloadTheme(href).then(s => (themes[name] = s));
},
set theme(name) {
selectTheme(themes, name);
},
get theme() {
return Object.keys(themes).find(n => !themes[n].disabled);
}
};
};
let app = new Vue({
el: '#app',
data() {
return {
themes: {
flatly: 'https://bootswatch.com/4/flatly/bootstrap.min.css',
materia: 'https://bootswatch.com/4/materia/bootstrap.min.css',
solar: 'https://bootswatch.com/4/solar/bootstrap.min.css'
},
themeHelper: new ThemeHelper(),
loading: true
};
},
created() {
// add/load themes
let added = Object.keys(this.themes).map(name => {
return this.themeHelper.add(name, this.themes[name]);
});
Promise.all(added).then(sheets => {
console.log(`${sheets.length} themes loaded`);
this.loading = false;
this.themeHelper.theme = 'flatly';
});
}
});
<script src="https://unpkg.com/vue#2.5.2/dist/vue.js"></script>
<div id="app">
<p v-if="loading">loading...</p>
<select v-model="themeHelper.theme">
<option v-for="(href, name) of themes" v-bind:value="name">
{{ name }}
</option>
</select>
<span>Selected: {{ themeHelper.theme }}</span>
</div>
<hr>
<h3>Select a theme above</h3>
<button class="btn btn-primary">A Button</button>
Related
I am learning vuetify and vue js and I want to know how to get params when I click on my treeview :
example :
in the console chrome with vue extension I have :
vue event update:active
(this gives me the id of the element in my treeview)
And I want to compare the params in a function to do some actions when I click on an element of my treeview, how can I do that ?
<v-treeview
v-if="userExists"
hoverable
open-on-click
#update:active="openRoute"
:items="items"
activatable>
</v-treeview>
and the function I made
function openRoute () {
const routes = _.map(items, function (item) {
if (item.name === 'Structure') { // here I want to compare with the id
return item.routeName
}
})
const test = routes.toString()
context.root.$router.push({ name: routes })
}
Thank you for you help
You can simply add the parameter to your function. For example:
function openRoute (emitedValue) {
console.log(emitedValue);
const routes = _.map(items, function (item) {
if (item.name === 'Structure') { // here I want to compare with the id
return item.routeName
}
})
const test = routes.toString()
context.root.$router.push({ name: routes })
}
I am writing my eslint rule using vue-eslint-parser. It's required to prevent functional components from using vue template syntax.
I have already written a rule for prohibiting the syntax <template functional> :
create(context) {
return context.parserServices.defineDocumentVisitor(
{
VDocumentFragment(node) {
const template = node.children.find(item => item.type === 'VElement' && item.name === 'template');
if (!template) return;
const functionalAttr = template.startTag.attributes.find(item => !item.directive && item.key.name === 'functional');
if (functionalAttr) {
context.report({
message: "Don't use vue templates with functional components",
loc: node.loc
});
}
}
}
);
}
Question:
How do I make the following code also invalid ? (in other words, how to check the script tag and the code inside it).
<template>
</template>
<script>
export default {
functional: true,
};
</script>
Thank you!
Link to the (not very clear) documentation where I found the solution :
create(context: Rule.RuleContext) {
return context.parserServices.defineTemplateBodyVisitor(
{
VElement(node: VElement): void {
if (node.name !== 'template') return;
const functionalAttr = node.startTag.attributes.find(item => !item.directive && item.key.name === 'functional');
if (functionalAttr) {
context.report({
message: "Don't use 'functional' attribute in vue templates. Use render function instead of vue templates for functional components.",
loc: node.loc
});
}
}
},
{
Program(node: ESLintProgram): void {
if (!node.templateBody) return;
const targetNode = node.body.find(item => item.type === 'ExportDefaultDeclaration') as ExportDefaultDeclaration || undefined;
if (!targetNode) return;
const functionalOption = (targetNode.declaration as ObjectExpression).properties.find(item => item.type === 'Property' && (item.key as Identifier).name === 'functional');
if (functionalOption) {
context.report({
message: "Don't use 'functional' property along with vue templates. Use render function instead of vue templates for functional components.",
loc: node.loc
});
}
}
}
);
}
I have a filter input field and want to filter a list of items. The list is large so I want to use debounce to delay the filter being applied until the user has stopped typing for improved user experience. This is my input field and it's bound to filterText that is used to filter the list.
<input type="text" v-model="state.filterText" />
I didn't find any nice solution as I wanted to see my binding in my template so I decided to share my solution. I wrote a simple debounce function and use the following syntax to bind the behavior:
setup() {
...
function createDebounce() {
let timeout = null;
return function (fnc, delayMs) {
clearTimeout(timeout);
timeout = setTimeout(() => {
fnc();
}, delayMs || 500);
};
}
return {
state,
debounce: createDebounce(),
};
},
And the template syntax:
<input
type="text"
:value="state.filterText"
#input="debounce(() => { state.filterText = $event.target.value })"
/>
Hi first time answering something here, so correct my answer as much as you want, I'd appreciate it.
I think that the prettiest and lightest solution is to create a directive globally that you can use as much as you want in all of your forms.
you first create the file with your directive, eg.
debouncer.js
and you create the function for the debouncing
//debouncer.js
/*
This is the typical debouncer function that receives
the "callback" and the time it will wait to emit the event
*/
function debouncer (fn, delay) {
var timeoutID = null
return function () {
clearTimeout(timeoutID)
var args = arguments
var that = this
timeoutID = setTimeout(function () {
fn.apply(that, args)
}, delay)
}
}
/*
this function receives the element where the directive
will be set in and also the value set in it
if the value has changed then it will rebind the event
it has a default timeout of 500 milliseconds
*/
module.exports = function debounce(el, binding) {
if(binding.value !== binding.oldValue) {
el.oninput = debouncer(function(){
el.dispatchEvent(new Event('change'))
}, parseInt(binding.value) || 500)
}
}
After you define this file you can go to your main.js import it and use the exported function.
//main.js
import { createApp } from 'vue'
import debounce from './directives/debounce' // file being imported
const app = createApp(App)
//defining the directive
app.directive('debounce', (el,binding) => debounce(el,binding))
app.mount('#app')
And its done, when you want to use the directive on an input you simply do it like this, no imports or anything.
//Component.vue
<input
:placeholder="filter by name"
v-model.lazy="filter.value" v-debounce="400"
/>
The v-model.lazy directive is important if you choose to do it this way, because by default it will update your binded property on the input event, but setting this will make it wait for a change event instead, which is the event we are emitting in our debounce function. Doing this will stop the v-model updating itself until you stop writing or the timeout runs out (which you can set in the value of the directive).
I hope this was understandable.
<template>
<input type="text" :value="name" #input="test" />
<span>{{ name }}</span>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
function debounce<T> (fn: T, wait: number) {
let timer: ReturnType<typeof setTimeout>
return (event: Event) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
if (typeof fn === 'function') {
fn(event)
}
}, wait)
}
}
export default defineComponent({
setup () {
const name = ref('test')
function setInputValue (event: Event) {
const target = event.target as HTMLInputElement
name.value = target.value
}
const test = debounce(setInputValue, 1000)
return { name, test }
}
})
</script>
With Lodash, you have an easier solution:
<template>
<input type="text" :value="name" #input="onInput" />
<span>{{ name }}</span>
</template>
<script>
import debounce from "lodash/debounce"
export default {
setup () {
const onInput = debounce(() => {
console.log('debug')
}, 500)
return { onInput }
}
}
</script>
<input #input="updateValue"/>
const updateValue = (event) => {
const timeoutId = window.setTimeout(() => {}, 0);
for (let id = timeoutId; id >= 0; id -= 1) {
window.clearTimeout(id);
}
setTimeout(() => {
console.log(event.target.value)
}, 500);
};
You can try this one
Here's an example with Lodash and script setup syntax using a watcher to fire the debounced api call:
<script setup>
import { ref, watch } from 'vue'
import debounce from 'lodash.debounce'
const searchTerms = ref('')
const getFilteredResults = async () => {
try {
console.log('filter changed')
// make axios call here using searchTerms.value
} catch (err) {
throw new Error(`Problem filtering results: ${err}.`)
}
}
const debouncedFilter = debounce(getFilteredResults, 250) // 250ms delay
watch(() => searchTerms.value, debouncedFilter)
</script>
<template>
<input v-model="searchTerms" />
</template>
https://www.npmjs.com/package/vue-debounce now works for vue 3
It can be registered also with composition API like this
setup() {
...
},
directives: {
debounce: vue3Debounce({ lock: true })
}
I have a Vue app (and I'm relatively new to Vue), anyway I have a generic error modal which is displayed when any of my axios calls fail.
On the modal, I want to be able to retry the failed process when the 'Retry' button is clicked but I'm struggling a bit on how to achieve this. I don't think props will help me as the modal is triggered by
VueEvent.$emit('show-error-modal')
I have managed in my catch to pass the function which has failed by using
VueEvent.$emit('show-error-modal', (this.test));
Then in my modal, I have access to it using
created() {
VueEvent.$on('show-error-modal', (processFailed) => {
console.log('processFailed', processFailed)
this.processFailed = processFailed;
$('#errorModal').modal('show').on('shown.bs.modal', this.focus);
});
}
Using 'F12' it gives
test: function test() {
var _this2 = this;
var page = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
alert('boo');
this.loading = true;
var order_by = {
'ho_manufacturers.name': 4,
'services.servicename': 4
};
axios.post('/test/url', {
id: this.selectedManufacturers,
page: page,
order_by: order_by
}).then(function (response) {
var paginator = response.data.paginator;
_this2.$store.dispatch('paginateProducts', paginator);
_this2.$store.dispatch('setProductPaginator', paginator);
_this2.loading = false;
})["catch"](function (error) {
var processFailed = 'fetchProducts';
_this2.loading = false;
VueEvent.$emit('show-error-modal', _this2.test);
console.log(error);
});
},
I don't think this is the right way of doing it though as all this. are replaced with _this2 as shown above. I need the modal to be generic so I can reuse it but I can't figure it out how to retry the process when clicking the button.
The modals are registered in my app.vue file as
<template>
<div>
<!-- Other code here -->
<app-error-modal />
</div>
</template>
<script>
// Other code here
import ErrorModal from './components/ErrorModal';
export default {
name: 'App',
components: {
// Other code here
appErrorModal: ErrorModal
}
// Other code here
</script>
Modal button HTML
<button ref="retryButton" class="btn btn-success col-lg-2" type="submit" data-dismiss="modal" #click="retryProcess">Retry</button>
Modal script code
<script>
export default {
name: "ErrorModal",
created() {
VueEvent.$on('show-error-modal', (processFailed) => {
console.log('processFailed', processFailed)
this.processFailed = processFailed;
$('#errorModal').modal('show').on('shown.bs.modal', this.focus);
});
},
methods: {
focus() {
this.$refs.retryButton.focus();
},
retryProcess() {
//this.$parent.test(); TRIED THIS BUT DIDN'T WORK
}
}
}
</script>
I'd rather not have to the store.
Use custom event on your component
<div id="app">
<error-modal v-if="isError" v-on:retry-clicked="retry"></error-modal>
<button #click="isError = true">Make Error</button>
</div>
const errorModal = {
template : "<button #click=\"$emit('retry-clicked')\">Retry</button>"
}
new Vue({
el : "#app",
data : {
isError : false
},
components : {
errorModal
},
methods : {
retry : function(){
this.isError = false;
console.log("child component has called parent when retry clicked")
}
}
})
Custom event on component - VUEJS DOC
Everything you have there looks correct. You are passing the function to retry ("test") as "processFailed" - I would call that something different such as retryFn.
Then in your error modal component you just need:
<button #click="processFailed">Retry</button>
Don't worry about what the browser shows you in F12, that is the transpiled Javascript, it will work fine.
We're in the process of retrofitting a mature website with some updated forms. We're replacing some systems entirely with SPAs and some just don't warrant it.
However, we have some system-global modal screens that we need to be available. Some of these have been ported to Vue and work well enough inside SPAs and we'd like to reap the benefits of heightened interactivity in our non-SPA pages.
So whereas before we would have had our event bound like so:
$('a.newBooking').on('click', function(ev){
// code to open bootstrap modal screen, download HTML, etc.
});
How do we spin up a component?
I know I haven't included any serious code here but our modals are basically just embellished versions of the documented example modal component. The difference is we don't have the button. We'll want to be able to launch these modals from all around the page.
My opinion:
For your Modal components:
use singleton pattern for your modal (because basically we only allow one modal popup at the same time), it will make the logic more simple.
customize one install function to add the Vue instances of your Modals to Vue.prototype, like _Vue.prototype.$my = yourModals
then register your plugins in demand like Vue.use(installFunc, {plugins: [SModal, AModal, BModal]})
At your JQuery (or other non-Vue) Apps:
Register Modals to Vue, then create Vue instance
show or hide your modals like vueInstance.$my.SModal.show
Below is one simple demo:
Vue.config.productionTip = false
/*---Modal Plugin---*/
let vm = null // the instance for your Vue modal
let timeout = null //async/delay popup
const SModal = {
isActive: false,
show ({
delay = 500,
message = '',
customClass = 'my-modal-class'
} = {}) {
if (this.isActive) {
vm && vm.$forceUpdate()
return
}
timeout = setTimeout(() => {
timeout = null
const node = document.createElement('div')
document.body.appendChild(node)
let staticClass = ''
vm = new this.__Vue({
name: 's-modal',
el: node,
render (h) { // uses render() which is a closer-to-the-compiler alternative to templates
return h('div', {
staticClass,
'class': customClass,
domProps: {
innerHTML: message
}
})
}
})
}, delay)
this.isActive = true
},
hide () {
if (!this.isActive) {
return
}
if (timeout) {
clearTimeout(timeout)
timeout = null
} else {
vm.$destroy()
document.body.removeChild(vm.$el)
vm = null
}
this.isActive = false
},
__Vue: null,
__installed: false,
install ({ $my, Vue }) {
if (this.__installed) { return }
this.__installed = true
$my.SModal = SModal // added your SModal object to $my
this.__Vue = Vue //get the Vue constructor
}
}
/*---Modal Plugin End---*/
/*---Custom Install Function in order to manage all modals---*/
let installFunc = function (_Vue, opts = {}) {
if (this.__installed) {
return
}
this.__installed = true
const $my = {
'memo': 'I am a plugin management.'
}
if (opts.plugins) {
Object.keys(opts.plugins).forEach(key => {
const p = opts.plugins[key]
if (typeof p.install === 'function') {
p.install({ $my, Vue: _Vue })
}
})
}
_Vue.prototype.$my = $my
}
/*---Install Plugins---*/
Vue.use(installFunc, {
plugins: [SModal]
})
let globalVue = new Vue({
el: '#vue-app'
})
$('#test').on('click', 'span', function () {
globalVue.$my.SModal.isActive ? globalVue.$my.SModal.hide() : globalVue.$my.SModal.show({'message':'test', 'delay':100})
})
span {
cursor:pointer;
color:red;
}
.my-modal-class {
position:absolute;
top:50px;
left:150px;
width:200px;
height:200px;
background-color:red;
z-index:9999;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.js"></script>
<div id="vue-app">
</div>
<div id="test">
<h3>One Example</h3>
<p><span>Hello</span>, how are you?</p>
<p>Me? I'm <span>good</span>.</p>
</div>