vuetifyjs / vuetify

🐉 Vue Component Framework
https://vuetifyjs.com
MIT License
39.62k stars 6.95k forks source link

[Feature Request] Add supoort for <inertia-link> component #11573

Closed MtDalPizzol closed 3 years ago

MtDalPizzol commented 4 years ago

Problem to solve

I'm using Inertia.js with Laravel after a lot of frustration with Nuxt. Inertia do it's navigation magic with the <inertia-link> component. It's something linke <nuxt-link>. It would be nice to have support for this on <v-btn>, <v-chip> just like we have for nuxt.

Proposed solution

It could be something as simple as: <v-btn inertia to="route">. The same deal going on with <v-btn nuxt to="route>

KaelWD commented 3 years ago

Thank you for the Feature Request and interest in improving Vuetify. Unfortunately this is not functionality that we are looking to implement at this time.

wfjsw commented 3 years ago

This really sounds like a deal breaker to me. With BootstrapVue there is a workaround (see https://github.com/bootstrap-vue/bootstrap-vue/issues/5759) as there is a way to assign custom link component to it. Is there any similar workaround here?

garrensweet commented 3 years ago

This really sounds like a deal breaker to me. With BootstrapVue there is a workaround (see bootstrap-vue/bootstrap-vue#5759) as there is a way to assign custom link component to it. Is there any similar workaround here?

Stumbled across this and wanted to an answer as well. I found that the inertia-link component has the as property which accepts any DOM element or vue component. Additionally, the props passed to the inertia-link component are passed to the rendered component implementation when using as so you're able to leverage the standard Vuetify v-btn properties on it as well and it all works great.

<inertia-link as="v-btn" href="/" color="secondary" block small> Link </inertia-link>

wfjsw commented 3 years ago

Document my workaround here:

Component Code ```typescript // @ts-nocheck import Vue, { VNodeData } from 'vue' import {VBtn} from 'vuetify/lib' export default Vue.extend({ extends: VBtn, methods: { generateRouteLink() { let exact = this.exact let tag const data: VNodeData = { attrs: { tabindex: 'tabindex' in this.$attrs ? this.$attrs.tabindex : undefined, }, class: this.classes, style: this.styles, props: {}, directives: [{ name: 'ripple', value: this.computedRipple, }], // [this.to ? 'nativeOn' : 'on']: { 'on': { ...this.$listeners, click: this.click, }, ref: 'link', } if (typeof this.exact === 'undefined') { exact = this.to === '/' || (this.to === Object(this.to) && this.to.path === '/') } if (this.to) { // Add a special activeClass hook // for component level styles let activeClass = this.activeClass let exactActiveClass = this.exactActiveClass || activeClass if (this.proxyClass) { activeClass = `${activeClass} ${this.proxyClass}`.trim() exactActiveClass = `${exactActiveClass} ${this.proxyClass}`.trim() } tag = 'inertia-link' Object.assign(data.props, { href: this.to, exact, activeClass, exactActiveClass, append: this.append, replace: this.replace, }) } else { tag = (this.href && 'a') || this.tag || 'div' if (tag === 'a' && this.href) data.attrs!.href = this.href } if (this.target) data.attrs!.target = this.target return { tag, data } } } }) ```
CPSibo commented 3 years ago

<inertia-link as="v-btn" href="/" color="secondary" block small> Link </inertia-link>

Worth noting that this only works for vue components that are actually included in the build process. If you're using treeshaking and automatic component loading but aren't referencing v-btn anywhere outside of the inertia-link, you'll just get a literal <v-btn> tag and an error.

You'll either have to manually reference the component:

import { VBtn } from 'vuetify/lib'

export default {
  //...
  components: {
    VBtn,
  }
  //...
}

or use the v-btn tag normally somewhere else.

Probably a non-issue for the v-btn component (unless you're doing a small prototype like me) but could affect a less common component.

OrkhanAlikhanov commented 2 years ago

In my case I was not using vue router in the project, so I registered the inertia-link globally as router-link.

Vue.component('router-link', {
  functional: true,
  render(h, context) {
    const data = { ...context.data }
    delete data.nativeOn
    const props = data.props as any || {}
    props.href = props.to /// v-btn passes `to` prop but inertia-link requires `href`, so we just copy it
    return h('inertia-link', data, context.children)
  },
})
ibnu-ja commented 2 years ago

anyone know how to do it on Vuetify3?

ThaDaVos commented 2 years ago

I was looking for this too after I noticed everywhere we used v-btn that the whole page was getting reloaded - gonna try @OrkhanAlikhanov solution

Edit:

Sadly I cannot get the solution to work with VuetifyJs 3.x

jakemake commented 1 year ago

In my case I was not using vue router in the project, so I registered the inertia-link globally as router-link.

Vue.component('router-link', {
  functional: true,
  render(h, context) {
    const data = { ...context.data }
    delete data.nativeOn
    const props = data.props as any || {}
    props.href = props.to /// v-btn passes `to` prop but inertia-link requires `href`, so we just copy it
    return h('inertia-link', data, context.children)
  },
})

is there any solution to get this work on vue 3 + vuetify 3

alexanderfriederich commented 1 year ago

I use a helper function for Inertia Links within vuetify 3 with vue 3.

see: https://gist.github.com/alexanderfriederich/887ecfd3ae99d54445a4126bb302d2d7

Import it, and use it like:

<v-list-item @click="triggerInertiaLink('accounts.index')">
   <v-list-item-title>Accounts</v-list-item-title>
</v-list-item>

Works flawless in my vue 3 + vuetify 3 setup

jakemake commented 1 year ago

I use a helper function for Inertia Links within vuetify 3 with vue 3.

see: https://gist.github.com/alexanderfriederich/887ecfd3ae99d54445a4126bb302d2d7

Import it, and use it like:

<v-list-item @click="triggerInertiaLink('accounts.index')">
   <v-list-item-title>Accounts</v-list-item-title>
</v-list-item>

Works flawless in my vue 3 + vuetify 3 setup

yes, but in this case you cannot use browsers "Open in new tab" context menu action

ibnu-ja commented 1 year ago

Open link.js from inertia-vue3 source code and change as prop type to accept both string and object

...
as: {
      type: String,
      default: 'a'
    },

to

...
as: {
      type: [String, Object],
      default: 'a'
    },

save it as new file, import it now use the component with vuetify component for as prop

<Link :as="VBtn" :href="route('home')" :active="route().current('home')" label="Home" />
MKRazz commented 1 year ago

I'm trying Inertia with Vue 3 + Vuetify 3 for a POC and ran into this issue. I came up with another solution that seems to be working well so far, but I have only tried it for my specific use case (making a <v-list-item /> a link with to) so it may need additional work, but I figure this can be a good starting point for others still having issues with this.

A plugin to create a fake RouterLink component that provides the useLink function that Vuetify uses. I just did a basic implementation of isActive and isExactActive; it may not behave exactly as VueRouter's does.

import {computed} from 'vue';
import {Inertia} from '@inertiajs/inertia';
import {useBrowserLocation} from '@vueuse/core';

export default {
    install(app, options) {
        app.component('RouterLink', {
            useLink(props) {
                const browserLocation = useBrowserLocation();
                const currentUrl = computed(() => `${browserLocation.value.origin}${browserLocation.value.pathname}`);

                return {
                    route: computed(() => ({href: props.to})),
                    isExactActive: computed(() => currentUrl.value === props.to),
                    isActive: computed(() => currentUrl.value.startsWith(props.to)),
                    navigate(e) {
                        if (e.shiftKey || e.metaKey || e.ctrlKey) return;

                        e.preventDefault();

                        Inertia.visit(props.to);
                    }
                }
            },
        });
    },
};

Just install that as a plugin as you normally would createApp()...use(inertia).... Then you can just use :to as you normally would in Vuetify:

<v-list-item :to="route('index')">
  <v-list-item-title>
     Home
  </v-list-item-title>
</v-list-item>

Note: This will only apply to to and not href, so you can use to when you want to use internal Inertia routing, and href for standard external routing.

gcaraciolo commented 1 year ago

I'm trying Inertia with Vue 3 + Vuetify 3 for a POC and ran into this issue. I came up with another solution that seems to be working well so far, but I have only tried it for my specific use case (making a <v-list-item /> a link with to) so it may need additional work, but I figure this can be a good starting point for others still having issues with this.

A plugin to create a fake RouterLink component that provides the useLink function that Vuetify uses. I just did a basic implementation of isActive and isExactActive; it may not behave exactly as VueRouter's does.

import {computed} from 'vue';
import {Inertia} from '@inertiajs/inertia';
import {useBrowserLocation} from '@vueuse/core';

export default {
    install(app, options) {
        app.component('RouterLink', {
            useLink(props) {
                const browserLocation = useBrowserLocation();
                const currentUrl = computed(() => `${browserLocation.value.origin}${browserLocation.value.pathname}`);

                return {
                    route: computed(() => ({href: props.to})),
                    isExactActive: computed(() => currentUrl.value === props.to),
                    isActive: computed(() => currentUrl.value.startsWith(props.to)),
                    navigate(e) {
                        if (e.shiftKey || e.metaKey || e.ctrlKey) return;

                        e.preventDefault();

                        Inertia.visit(props.to);
                    }
                }
            },
        });
    },
};

Just install that as a plugin as you normally would createApp()...use(inertia).... Then you can just use :to as you normally would in Vuetify:

<v-list-item :to="route('index')">
  <v-list-item-title>
     Home
  </v-list-item-title>
</v-list-item>

Note: This will only apply to to and not href, so you can use to when you want to use internal Inertia routing, and href for standard external routing.

An improvement I needed was to add

 router.visit(props.to, {
    method: e.currentTarget.getAttribute('method') || 'get'
});

to make thinkgs like logout this way:

<VListItem
    :prepend-icon="mdiLogoutVariant"
    :to="route('logout')"
    method="post"
    title="Logout"
/>
maxflex commented 1 year ago

My setup

import { router, usePage } from "@inertiajs/vue3"
import { computed } from "vue"

export default {
  install(app) {
    app.component("RouterLink", {
      useLink(props) {
        const href = props.to
        const currentUrl = computed(() => usePage().url)
        return {
          route: computed(() => ({ href })),
          isActive: computed(() => currentUrl.value.startsWith(href)),
          isExactActive: computed(() => href === currentUrl.value),
          navigate(e) {
            if (e.shiftKey || e.metaKey || e.ctrlKey) return
            e.preventDefault()
            router.visit(href)
          },
        }
      },
    })
  },
}
hw-rjuzak commented 1 year ago

@maxflex Could you elaborate this a little bit? Where to put this code?

maxflex commented 1 year ago

@maxflex Could you elaborate this a little bit? Where to put this code?

As @MKRazz suggested, you can create a plugin and then use it in createApp settings. The idea is to modify the link object for useLink() composable that Vuetify uses

robjuz commented 1 year ago

Leaving this for reference hier:

Laraverl 9 + Inertia 1.0 + Vue 3 + Vuetify 3

Basic setup

package.json

{
    "private": true,
    "scripts": {
        "dev": "vite --host",
        "build": "vite build"
    },
    "dependencies": {
        "@inertiajs/vue3": "^1.0.2",
        "@types/ziggy-js": "^1.3.2",
        "vuetify": "^3.1.8"
    },
    "devDependencies": {
        "@mdi/js": "^7.1.96",
        "@types/node": "^18.14.6",
        "@vitejs/plugin-vue": "^3.0.0",
        "laravel-vite-plugin": "^0.7.2",
        "typescript": "^4.9.5",
        "vue": "^3.2.31"
    }
}

app.js

import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m';

import vuetify from "@/plugins/vuetify";
import link from "@/plugins/link";

const appName = window.document.getElementsByTagName('title')[0]?.innerText || 'Laravel';

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
    progress: {
        color: '#29d',
    },
    setup({ el, App, props, plugin }) {
        return createApp({ render: () => h(App, props) })
            .use(plugin)
            .use(ZiggyVue)
            .use(vuetify)
            .use(link)
            .mount(el);
    },
});

plugins/vuetify.ts

import 'vuetify/styles'

import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'
import { mdiHome } from "@mdi/js";

export default createVuetify({
    components,
    directives,
    defaults: {
        VTextField: {
            variant: 'outlined'
        }
    },
    icons: {
        defaultSet: 'mdi',
        aliases: {
            ...aliases,
            home: mdiHome,
        },
        sets: {
            mdi,
        }
    },
})

link.ts

import { router, usePage } from "@inertiajs/vue3"
import { computed } from "vue"

export default {
  install(app) {
    app.component("RouterLink", {
      useLink(props) {
        const href = props.to
        const currentUrl = computed(() => usePage().url)
        return {
          route: computed(() => ({ href })),
          isActive: computed(() => currentUrl.value.startsWith(href)),
          isExactActive: computed(() => href === currentUrl),
          navigate(e) {
            if (e.shiftKey || e.metaKey || e.ctrlKey) return
            e.preventDefault()
            router.visit(href)
          },
        }
      },
    })
  },
}

Usage

Notice the usage of :to="..."

<v-btn v-if="canResetPassword" variant="plain" size="small"  :to="route('password.request')">
   Forgot your password?
</v-btn>

App.vue

<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';

defineProps({
    canResetPassword: Boolean,
    status: String,
});

const form = useForm({
    email: '',
    password: '',
    remember: false
});

const submit = () => {
    form.post(route('login'), {
        onFinish: () => form.reset('password'),
    });
};
</script>

<template>
    <GuestLayout>
        <Head title="Log in"/>

        <v-alert v-if="status">
            {{ status }}
        </v-alert>

        <v-card class="mx-auto" width="100%" max-width="344">
            <v-toolbar>
                <v-toolbar-title>Login</v-toolbar-title>
            </v-toolbar>

            <v-form @submit.prevent="submit">
                <v-container>
                    <v-text-field
                            v-model="form.email"
                            :error-messages="form.errors.email"
                            autocomplete="username"
                            autofocus
                            label="Email"
                            required
                            type="email"
                            class="mb-2"
                    />

                    <v-text-field
                            v-model="form.password"
                            :error-messages="form.errors.password"
                            autocomplete="current-password"
                            autofocus
                            label="Password"
                            required
                            type="password"
                    />

                    <v-checkbox
                            v-model="form.remember"
                            name="remember"
                            label="Remember me"
                    />

                    <v-btn v-if="canResetPassword" variant="plain" size="small"
                           :to="route('password.request')"
                    >
                        Forgot your password?
                    </v-btn>

                </v-container>

                <v-divider></v-divider>

                <v-card-actions>
                    <v-spacer></v-spacer>
                    <v-btn color="success" :loading="form.processing" type="submit">
                        Log in
                    </v-btn>
                </v-card-actions>
            </v-form>
        </v-card>
    </GuestLayout>
</template>
tangamampilia commented 1 year ago

Not sure if this is the right way, but I found a more simple solution.

<Link  :href="route('route.index')" method="get" as="div" class="d-inline">
    <v-btn type="button">Submit</v-btn>
</Link>
robjuz commented 1 year ago

@tangamampilia This will produce a button tag inside an a tag, what is not a valid html

https://stackoverflow.com/a/6393863

jakemake commented 1 year ago

Not sure if this is the right way, but I found a more simple solution.

<Link  :href="route('route.index')" method="get" as="div" class="d-inline">
    <v-btn type="button">Submit</v-btn>
</Link>

This will not applyable to v-list 's, actually will, but you probably face some difficulties in setting routing active states

tangamampilia commented 1 year ago

@tangamampilia This will produce a button tag inside an a tag, what is not a valid html

https://stackoverflow.com/a/6393863

Actually it wrap the button inside of a div, the attribute as defines the parent element. I know it's not the best solution, but seems to be working even with the routing states.

Mohammad-Alavi commented 4 months ago

For anyone having problems using this awersome solution after upgradting to Vuetify v3.5.14:

Update this line in the link.ts:

- const href = props.to;
+ const href = props.to.value;

This is needed because Vuetify fixed a bug and props.to is now reactive.

Links for reference: https://github.com/vuetifyjs/vuetify/releases/tag/v3.5.14 https://github.com/vuetifyjs/vuetify/issues/19515

gigerIT commented 4 months ago

Hello Everyone

I decided to create a package to maintain this excellent functionality across all my projects, should Vuetify or Intertia publish changes in the future.

You can find the package here: https://www.npmjs.com/package/vuetify-inertia-link

Many thanks to @robjuz !

ibnu-ja commented 4 months ago

I adapted https://github.com/inertiajs/inertia/blob/master/packages/vue3/src/link.ts to accept component for the :as props

<script lang="ts" setup>
import {
  ComponentOptionsBase,
  computed,
  ComputedOptions,
  CreateComponentPublicInstance,
  FunctionalComponent,
  h,
  MethodOptions,
  useAttrs,
  useSlots,
  VNode,
} from 'vue'

import {
  FormDataConvertible,
  mergeDataIntoQueryString,
  Method,
  PreserveStateOption,
  router,
  shouldIntercept,
} from '@inertiajs/core'
import { useBrowserLocation } from '@vueuse/core'

/* eslint-disable @typescript-eslint/no-explicit-any */
type CustomComponentType =
  (ComponentOptionsBase<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, any> & ThisType<CreateComponentPublicInstance<any, any, any, ComputedOptions, MethodOptions, any, any, any, Readonly<any>>>)
  | FunctionalComponent<any, any>

/* eslint-enable */

interface ModifiedInertiaLinkProps {
  method?: Method
  replace?: boolean
  preserveScroll?: PreserveStateOption
  preserveState?: PreserveStateOption
  only?: Array<string>
  headers?: Record<string, string>
  errorBag?: string | null
  forceFormData?: boolean
  queryStringArrayFormat?: 'indices' | 'brackets'
  href?: string | null
  as?: string | CustomComponentType
  active?: boolean
  exactActive?: boolean
  data?: Record<string, FormDataConvertible>
}

const props = withDefaults(defineProps<ModifiedInertiaLinkProps>(), {
  href: null,
  method: 'get',
  data: () => ({}),
  as: 'a',
  preserveScroll: false,
  preserveState: false,
  replace: false,
  headers: () => ({}),
  errorBag: null,
  queryStringArrayFormat: () => ('brackets'),
  active: () => false,
  exactActive: () => false,
  boolean: () => false,
  only: () => ([]),
})

const slots = useSlots()
let as = props.as
const method = props.method

const attrs = useAttrs()
let component: string | VNode
const [href, data] = mergeDataIntoQueryString(props.method, props.href || '', props.data, props.queryStringArrayFormat)
const browserLocation = useBrowserLocation()

const computedActive = computed(() => {
  if (!browserLocation.value.pathname || !props.href) {
    return false
  }
  const currentUrl = browserLocation.value.origin + browserLocation.value.pathname.replace(/\/$/, '')
  const hrefWithoutTrailingSlash = props.href.replace(/\/$/, '')
  if (props.active) {
    return true
  }
  if (props.exactActive) {
    // bad
    // return `${browserLocation.value.origin}${browserLocation.value.pathname}` === props.href
    return currentUrl === hrefWithoutTrailingSlash
  }
  return currentUrl.startsWith(props.href)
})

// vuetify-specific props

const onClick = (event: KeyboardEvent) => {
  if (shouldIntercept(event)) {
    event.preventDefault()
    router.visit(href, {
      data: data,
      method: method,
      replace: props.replace,
      preserveScroll: props.preserveScroll,
      preserveState: props.preserveState ?? method !== 'get',
      only: props.only,
      headers: props.headers,
      // @ts-expect-error TODO: Fix this
      onCancelToken: attrs.onCancelToken || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onBefore: attrs.onBefore || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onStart: attrs.onStart || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onProgress: attrs.onProgress || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onFinish: attrs.onFinish || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onCancel: attrs.onCancel || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onSuccess: attrs.onSuccess || (() => ({})),
      // @ts-expect-error TODO: Fix this
      onError: attrs.onError || (() => ({})),
    })
  }
}
if (typeof as === 'string') {
  as = as.toLowerCase()
  if (as === 'a' && method !== 'get') {
    console.warn(
      `Creating POST/PUT/PATCH/DELETE <a> links is discouraged as it causes "Open Link in New Tab/Window" accessibility issues.\n\nPlease specify a more appropriate element using the "as" attribute. For example:\n\n<LinkComponent href="${href}" method="${method}" as="button">...</LinkComponent>`,
    )
  }

  component = h(
    as,
    {
      active: computedActive.value,
      ...attrs,
      ...(as === 'a' ? { href } : {}),
      onClick,
    },
    slots,
  )
} else {
  component = h(
    as,
    {
      active: computedActive.value,
      ...attrs,
      href,
      onClick,
    },
    slots,
  )
}
</script>

<template>
  <component :is="component" />
</template>
<script lang="ts" setup>
import { VBtn } from 'vuetify/components'

import Link from '../components/InertiaLink.vue'
</script>

<template>
  <Link
    :as="VBtn"
    href="/playground"
    color="primary"
    block
  >
    to playground route
  </Link>
  <Link>
    empty link
  </Link>
</template>

it's closer to what inertia actually do instead of just calling router.visit() with both inertia link and component props support