I'm looking for a smart way to handle in-page anchors with Vue Router. Consider the following:
<router-link to="#app">Apply Now</router-link>
<!-- some HTML markup in between... -->
<div id="app">...</div>
The "scroll to anchor" behavior described in the docs works fine except:
When you click on the anchor, it brings you down to the div id="app". Now scroll away from the div back to the anchor and try clicking it again -- this time you will not jump down to the div. In fact, the anchor will retain the class router-link-active and the URL will still contain the hash /#app;
With the steps above, if you refresh the page (the URL will still contain the hash) and click on the anchor, nothing will happen either.
This is very unfortunate from the UX perspective because a potential customer has to manually scroll all the way down again to reach the application section.
I was wondering if Vue Router covers this situation. For reference, here's my router:
export default new VueRouter({
routes,
mode: 'history',
scrollBehavior(to, from, savedPosition) {
if (to.hash) {
return { selector: to.hash }
} else if (savedPosition) {
return savedPosition;
} else {
return { x: 0, y: 0 }
}
}
})
I haven't found anything in the resources to solve your issue but you could utitlize the $route.hash in your mounted hook of the component that holds your <router-view></router-view> to solve the refresh issue.
<script>
export default {
name: 'app',
mounted: function()
{
// From testing, without a brief timeout, it won't work.
setTimeout(() => this.scrollFix(this.$route.hash), 1);
},
methods: {
scrollFix: function(hashbang)
{
location.hash = hashbang;
}
}
}
</script>
Then to solve the issue of second clicks you could use the native modifier and bind to your <router-link></router-link>. It's a fairly manual process but will work.
<router-link to="#scroll" #click.native="scrollFix('#scroll')">Scroll</router-link>
There may also be something you could do with the router's afterEach method but haven't figured that out yet.
If you're already on the route with the hash, you can just set it to scroll to the target.
(also note scrollBehavior() method in your router won't get called if you're already on the route you're trying to go to).
export default {
methods: {
anchorHashCheck() {
if (window.location.hash === this.$route.hash) {
const el = document.getElementById(this.$route.hash.slice(1))
if (el) {
window.scrollTo(0, el.offsetTop)
}
}
},
},
mounted() {
this.anchorHashCheck()
},
}
Then add a #click.native to listen to events on the anchor in your <router-link>,
<router-link :to="{hash: '#some-link'}" #click.native="anchorHashCheck">
Some link
</router-link>
Possible solution which is more resusable IMO:
this.$router.push({ name: 'home' }, undefined, () => { location.href = this.$route.hash })
As the 3rd argument is the abort() function, it may have unwanted side effects though..
If you want to use it globally, add a function to your Router:
pushWithAnchor: function (routeName, toHash) {
const fromHash = Router.history.current.hash
fromHash !== toHash || !fromHash
? Router.push({ name: routeName, hash: toHash })
: Router.push({ name: routeName, hash: fromHash }, undefined, () => { window.location.href = toHash })
}
And use it in components with:
this.$router.options.pushWithAnchor('home', '#fee-calculator-section')
Within a template you could do something like:
<a #click="this.$router.options.pushWithAnchor('home', '#fee-calculator-section')"></a>
Sadly you cant use a scroll offset though
I used this solution:
<router-link to="/about" #click.native="scrollFix('#team')" >The team</router-link>
And this:
methods: {
scrollFix: function(hash) {
setTimeout(() => $('html, body').animate({
scrollTop: $(hash).offset().top
}, 1000), 1)
}
}
I realize you asked about anchors and you have an answer already. However, the below function should work for both anchors and regular links. It allows you to scroll to the position of the first instance of the Component a Route has been matched to. I wrote it to see if I could by-pass using a hash while retaining anchor-style scrolling.
scrollBehavior(to, from, savedPosition) {
if (to.matched) {
const children = to.matched[1].parent.instances.default.$children;
for (let i = 0; i < children.length; i++) {
let child = children[i];
if (child.$options._componentTag === to.matched[1].components.default.name) {
return {
x: child.$el.offsetLeft,
y: child.$el.offsetTop
};
}
}
}
return {
x: 0,
y: 0
};
}
The reason I'm using parent.instances is because the to.matched[1].instances value is empty. It's not the most elegant solution, though it might help someone else out there.
Note: This only works when you want to scroll the first instance of a Component.
Related
I was wondering if there is a way of creating computed props programatically, while still accessing the instance to achieve dynamic values
Something like that (this being undefined below)
<script>
export default {
computed: {
...createDynamicPropsWithTheContext(this), // helper function that returns an object
}
}
</script>
On this question, there is a solution given by Linus: https://forum.vuejs.org/t/generating-computed-properties-on-the-fly/14833/4 looking like
computed: {
...mapPropsModels(['cool', 'but', 'static'])
}
This works fine but the main issue is that it's fully static. Is there a way to access the Vue instance to reach upon props for example?
More context
For testing purposes, my helper function is as simple as
export const createDynamicPropsWithTheContext = (listToConvert) => {
return listToConvert?.reduce((acc, curr) => {
acc[curr] = curr
return acc
}, {})
}
What I actually wish to pass down to this helper function (via this) are props that are matching a specific prefix aka starting with any of those is|can|has|show (I'm using a regex), that I do have access via this.$options.props in a classic parent/child state transfer.
The final idea of my question is mainly to avoid manually writing all the props manually like ...createDynamicPropsWithTheContext(['canSubmit', 'showModal', 'isClosed']) but have them populated programatically (this pattern will be required in a lot of components).
The props are passed like this
<my-component can-submit="false" show-modal="true" />
PS: it's can-submit and not :can-submit on purpose (while still being hacked into a falsy result right now!).
It's for the ease of use for the end user that will not need to remember to prefix with :, yeah I know...a lot of difficulty just for a semi-colon that could follow Vue's conventions.
You could use the setup() hook, which receives props as its first argument. Pass the props argument to createDynamicPropsWithTheContext, and spread the result in setup()'s return (like you had done previously in the computed option):
import { createDynamicPropsWithTheContext } from './props-utils'
export default {
⋮
setup(props) {
return {
...createDynamicPropsWithTheContext(props),
}
}
}
demo
If the whole thing is for avoiding using a :, then you might want to consider using a simple object (or array of objects) as data source. You could just iterate over a list and bind the data to the components generated. In this scenario the only : used are in the objects
const comps = [{
"can-submit": false,
"show-modal": true,
"something-else": false,
},
{
"can-submit": true,
"show-modal": true,
"something-else": false,
},
{
"can-submit": false,
"show-modal": true,
"something-else": true,
},
]
const CustomComponent = {
setup(props, { attrs }) {
return {
attrs
}
},
template: `
<div
v-bind="attrs"
>{{ attrs }}</div>
`
}
const vm = Vue.createApp({
setup() {
return {
comps
}
},
template: `
<custom-component
v-for="(item, i) in comps"
v-bind="item"
></custom-component>
`
})
vm.component('CustomComponent', CustomComponent)
vm.mount('#app')
<script src="https://unpkg.com/vue#3"></script>
<div id="app">{{ message }}</div>
Thanks to Vue's Discord Cathrine and skirtle folks, I achieved to get it working!
Here is the thread and here is the SFC example that helped me, especially this code
created () {
const magicIsShown = computed(() => this.isShown === true || this.isShown === 'true')
Object.defineProperty(this, 'magicIsShown', {
get () {
return magicIsShown.value
}
})
}
Using Object.defineProperty(this... is helping keeping the whole state reactive and the computed(() => can reference some other prop (which I am looking at in my case).
Using a JS object could be doable but I have to have it done from the template (it's a lower barrier to entry).
Still, here is the solution I came up with as a global mixin imported in every component.
// helper functions
const proceedIfStringlean = (propName) => /^(is|can|has|show)+.*/.test(propName)
const stringleanCase = (string) => 'stringlean' + string[0].toUpperCase() + string.slice(1)
const computeStringlean = (value) => {
if (typeof value == 'string') {
return value == 'true'
}
return value
}
// the actual mixin
const generateStringleans = {
created() {
for (const [key, _value] of Object.entries(this.$props)) {
if (proceedIfStringlean(key)) {
const stringleanComputed = computed(() => this[key])
Object.defineProperty(this, stringleanCase(key), {
get() {
return computeStringlean(stringleanComputed.value)
},
// do not write any `set()` here because this is just an overlay
})
}
}
},
}
This will scan every .vue component, get the passed props and if those are prefixed with either is|can|has|show, will create a duplicated counter-part with a prefix of stringlean + pass the initial prop into a method (computeStringlean in my case).
Works great, there is no devtools support as expected since we're wiring it directly in vanilla JS.
I am trying to implement a menu with menu items scrolling to sections ids.
It works fine if I scroll on a section in the page that is on the page where I am currently.
To achieve it I installed vue-scrollto.
The problem is that the same menu is on other pages too, meaning that in that case it should load the page where the section is and scroll to it.
To give you an idea, it's a website previously done in jQuery the I am redoing in Nuxt. In jQuery I wass solving it with:
$('html,body').animate({
scrollTop: $(window.location.hash).offset().top -60
}, 1000);
So far the closest I got is:
<li class="nav-item"><nuxt-link to="/" v-scroll-to="{ element: '#intro', duration: 1000, offset: -100 }" >Welcome</nuxt-link></li>
But if for instance, I am in the about page, first it loads the homepage and if I click again it scrolls to the section. How can I make work without having to click the menu link twice?
I had a similar use case in Nuxt. I achieved using the following (although I didn't use the plugin you referred to):
~/app/router.scrollBehavior.js
export default async function (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
const findEl = (hash, x) => {
return document.querySelector(hash) ||
new Promise((resolve, reject) => {
if (x > 50) {
return resolve()
}
setTimeout(() => { resolve(findEl(hash, ++x || 1)) }, 100)
})
}
if (to.hash) {
const el = await findEl(to.hash)
if ('scrollBehavior' in document.documentElement.style) {
return window.scrollTo({ top: el.offsetTop, behavior: 'smooth' })
} else {
return window.scrollTo(0, el.offsetTop)
}
}
return { x: 0, y: 0 }
}
You can find more information about configuring Nuxt router and its scroll behaviour here.
I have registered 'beforeunload' event on created hook of the component used by routes of vue router.
I want to call this event handler in order to remove user on browser tab close or browser tab refresh or browser close.
On ComponentA
created (){
window.addEventListener('beforeunload', () => {
this.removeUser()
return null
})
}
Smilarly on ComponentB
created (){
window.addEventListener('beforeunload', () => {
this.removeUser()
return null
})
}
And my router.js
{
path: '/staff/call/:session_key',
name: 'Staff Call',
component: ComponentA,
meta: {auth: true}
},
{
path: '/consumer/call/:session_key',
name: 'Consumer Call',
component: ComponentB
},
Here 'beforeunload' event handler is triggered randomly. That is sometimes it get triggered and sometimes not. I count find any pattern when it is triggered and when it is not.
What am I missing here?
Edit
I'd guess the most likely culprit then is exactly what #PatrickSteele said. From MDN:
Note: To combat unwanted pop-ups, some browsers don't display prompts
created in beforeunload event handlers unless the page has been
interacted with; some don't display them at all. For a list of
specific browsers, see the Browser_compatibility section.
I'd say it's likely you're seeing inconsistent behavior because you are sometimes not interacting with the page.
This may be a syntax error. created should be a method
created () {
window.addEventListener('beforeunload', this.removeUser)
},
methods: {
removeUser () {
//remove user here
}
}
A fiddle working: https://jsfiddle.net/e6m6t4kd/3/
It's work for me. while do something before reload or close in
vue.js
created() {
window.onbeforeunload = function(){
return "handle your events or msgs here";
}
}
I had to do some fiddling on the above examples, I believe this is the most robust solution:
let app1 = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
data: {
dirty_form: true,
},
created () {
console.log('created')
window.addEventListener('beforeunload', this.confirm_leaving)
},
methods: {
confirm_leaving (evt) {
if (this.dirty_form) {
const unsaved_changes_warning = "You have unsaved changes. Are you sure you wish to leave?";
evt.returnValue = unsaved_changes_warning;
return unsaved_changes_warning;
};
};
},
});
If you want detect page refresh/change in Vue whenever you press F5 or Ctrl + R, You may need to use Navigation Timing API.
The PerformanceNavigation.type, will tell you how the page was accessed.
created() {
// does the browser support the Navigation Timing API?
if (window.performance) {
console.info("window.performance is supported");
}
// do something based on the navigation type...
if(performance.navigation.type === 1) {
console.info("TYPE_RELOAD");
this.removeUser();
}
}
Not sure why none of the above were fully working for me in vue 3 composition api. Abdullah's answer partially works but he left out how to remove the listener.
setup() {
const doSomething = (e) => {
// do stuff here
return true
}
onBeforeMount(() => {
window.onbeforeunload = handleLeaveWithoutSaving
})
onUnmounted(() => {
window.onbeforeunload = null
})
}
I have a small Vue.js SPA with the following router configuration, taken from the docs:
export default new VueRouter({
routes, // defined above...
mode: 'history',
scrollBehavior(to, from, savedPosition) {
if (to.hash) {
return { selector: to.hash }
} else if (savedPosition) {
return savedPosition;
} else {
return { x: 0, y: 0 }
}
}
})
Consider a link on the homepage:
<router-link to="#services">Services</router-link>
It jumps to the anchor element <div id="services">...</div> as expected. However, when you activate the link, then scroll away from #services, and refresh the page, you will not be brought back to #services. You will stay in the same position where you left off, even though the URL would still have the hash in it (e.g. in the form of app.dev/#services).
How can I configure the router so that on page load, it bring the user to the anchor element, given that the URL contains its hash (and, well, that hash corresponds to a valid existing element)?
I had the same problem but also wanted to have an animated scroll to the hash. I was able to check off both features with vue-scrollto.
https://github.com/rigor789/vue-scrollto
Something like this should work.
// import
import VueScrollTo from 'vue-scrollto';
//...
scrollBehavior(to, from, savedPosition) {
if (to.hash) {
VueScrollTo.scrollTo(to.hash, 700);
return { selector: to.hash }
} else if (savedPosition) {
return savedPosition;
} else {
return { x: 0, y: 0 }
}
}
That way, it always animates to the hash.
If you don't want to animate, use 0 as the time instead of 700.
If you don't like using that module, you can manually jump to the anchor via regular javascript using the techniques described here
As far as I know vuex-router-sync is just for synchronizing the route with the vuex store and the developer can access the route as follows:
store.state.route.path
store.state.route.params
However, I can also handle route by this.$route which is more concise.
When do I need to use the route in the store, and what is the scenario in which I need vuex-router-sync?
Here's my two cents. You don't need to import vuex-router-sync if you cannot figure out its use case in your project, but you may want it when you are trying to use route object in your vuex's method (this.$route won't work well in vuex's realm).
I'd like to give an example here.
Suppose you want to show a message in one component. You want to display a message like Have a nice day, Jack in almost every page, except for the case that Welcome back, Jack should be displayed when the user's browsing top page.
You can easily achieve it with the help of vuex-router-sync.
const Top = {
template: '<div>{{message}}</div>',
computed: {
message() {
return this.$store.getters.getMessage;
}
},
};
const Bar = {
template: '<div>{{message}}</div>',
computed: {
message() {
return this.$store.getters.getMessage;
}
}
};
const routes = [{
path: '/top',
component: Top,
name: 'top'
},
{
path: '/bar',
component: Bar,
name: 'bar'
},
];
const router = new VueRouter({
routes
});
const store = new Vuex.Store({
state: {
username: 'Jack',
phrases: ['Welcome back', 'Have a nice day'],
},
getters: {
getMessage(state) {
return state.route.name === 'top' ?
`${state.phrases[0]}, ${state.username}` :
`${state.phrases[1]}, ${state.username}`;
},
},
});
// sync store and router by using `vuex-router-sync`
sync(store, router);
const app = new Vue({
router,
store,
}).$mount('#app');
// vuex-router-sync source code pasted here because no proper cdn service found
function sync(store, router, options) {
var moduleName = (options || {}).moduleName || 'route'
store.registerModule(moduleName, {
namespaced: true,
state: cloneRoute(router.currentRoute),
mutations: {
'ROUTE_CHANGED': function(state, transition) {
store.state[moduleName] = cloneRoute(transition.to, transition.from)
}
}
})
var isTimeTraveling = false
var currentPath
// sync router on store change
store.watch(
function(state) {
return state[moduleName]
},
function(route) {
if (route.fullPath === currentPath) {
return
}
isTimeTraveling = true
var methodToUse = currentPath == null ?
'replace' :
'push'
currentPath = route.fullPath
router[methodToUse](route)
}, {
sync: true
}
)
// sync store on router navigation
router.afterEach(function(to, from) {
if (isTimeTraveling) {
isTimeTraveling = false
return
}
currentPath = to.fullPath
store.commit(moduleName + '/ROUTE_CHANGED', {
to: to,
from: from
})
})
}
function cloneRoute(to, from) {
var clone = {
name: to.name,
path: to.path,
hash: to.hash,
query: to.query,
params: to.params,
fullPath: to.fullPath,
meta: to.meta
}
if (from) {
clone.from = cloneRoute(from)
}
return Object.freeze(clone)
}
.router-link-active {
color: red;
}
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<script src="https://unpkg.com/vuex/dist/vuex.js"></script>
<div id="app">
<p>
<router-link to="/top">Go to Top</router-link>
<router-link to="/bar">Go to Bar</router-link>
</p>
<router-view></router-view>
</div>
fiddle here
As you can see, the components are well decoupled from vuex and vue-router's logic.
This pattern sometimes works really effectively for the case that you're not concerned about the relationship between current route and the value returned from vuex's getter.
I saw this thread when I was learning Vue. Added some of my understanding on the question.
Vuex defines a state management pattern for Vue applications. Instead of defining component props and passing the shared state through props in all the places, we use a centralized store to organize the state shared by multiple components. The restriction on state mutation makes the state transition clearer and easier to reason about.
Ideally, we should get / build consistent (or identical) views if the provided store states are the same. However, the router, shared by multiple components, breaks this. If we need to reason about why the page is rendered like it is, we need to check the store state as well as the router state if we derive the view from the this.$router properties.
vuex-router-sync is a helper to sync the router state to the centralized state store. Now all the views can be built from the state store and we don't need to check this.$router.
Note that the route state is immutable, and we should "change" its state via the $router.push or $router.go call. It may be helpful to define some actions on store as:
// import your router definition
import router from './router'
export default new Vuex.Store({
//...
actions: {
//...
// actions to update route asynchronously
routerPush (_, arg) {
router.push(arg)
},
routerGo (_, arg) {
router.go(arg)
}
}
})
This wraps the route updates in the store actions and we can completely get rid of the this.$router dependencies in the components.