mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt3): add <NuxtLink>
component (#3544)
Co-authored-by: pooya parsa <pyapar@gmail.com>
This commit is contained in:
parent
3c96417a65
commit
4cefce44a3
116
docs/content/3.docs/1.usage/9.routing.md
Normal file
116
docs/content/3.docs/1.usage/9.routing.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Routing
|
||||
|
||||
## `<NuxtLink>`
|
||||
|
||||
Nuxt provides `<NuxtLink>` component to handle any kind of links within your application.
|
||||
|
||||
`<NuxtLink>` component is a drop-in replacement for both Vue Router's `<RouterLink />` component and HTML's `<a>` tag. It intelligently determines whether the link is _internal_ or _external_ and renders it accordingly with available optimizations (prefetching, default attributes, etc.)
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic usage
|
||||
|
||||
In this example, we use `<NuxtLink>` component to link to a website.
|
||||
|
||||
```vue [app.vue]
|
||||
<template>
|
||||
<NuxtLink to="https://nuxtjs.org">
|
||||
Nuxt website
|
||||
</NuxtLink>
|
||||
<!-- <a href="https://nuxtjs.org" rel="noopener noreferrer">...</a> -->
|
||||
</template>
|
||||
```
|
||||
|
||||
:button-link[Open on StackBlitz]{href="https://stackblitz.com/github/nuxt/framework/tree/main/examples/nuxt-link?terminal=dev" blank}
|
||||
|
||||
### Internal routing
|
||||
|
||||
In this example, we use `<NuxtLink>` component to link to another page of the application.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<NuxtLink to="/about">
|
||||
About page
|
||||
</NuxtLink>
|
||||
<!-- <a href="/about">...</a> (+Vue Router & prefetching) -->
|
||||
</template>
|
||||
```
|
||||
|
||||
:button-link[Open on StackBlitz]{href="https://stackblitz.com/github/nuxt/framework/tree/main/examples/nuxt-link?terminal=dev" blank}
|
||||
|
||||
### `target` and `rel` attributes
|
||||
|
||||
In this example, we use `<NuxtLink>` with `target`, `rel`, and `noRel` props.
|
||||
|
||||
```vue [app.vue]
|
||||
<template>
|
||||
<NuxtLink to="https://twitter.com/nuxt_js" target="_blank">
|
||||
Nuxt Twitter
|
||||
</NuxtLink>
|
||||
<!-- <a href="https://twitter.com/nuxt_js" target="_blank" rel="noopener noreferrer">...</a> -->
|
||||
|
||||
<NuxtLink to="https://discord.nuxtjs.org" target="_blank" rel="noopener">
|
||||
Nuxt Discord
|
||||
</NuxtLink>
|
||||
<!-- <a href="https://discord.nuxtjs.org" target="_blank" rel="noopener">...</a> -->
|
||||
|
||||
<NuxtLink to="https://github.com/nuxt" no-rel>
|
||||
Nuxt GitHub
|
||||
</NuxtLink>
|
||||
<!-- <a href="https://github.com/nuxt">...</a> -->
|
||||
|
||||
<NuxtLink to="/contact" target="_blank">
|
||||
Contact page opens in another tab
|
||||
</NuxtLink>
|
||||
<!-- <a href="/contact" target="_blank" rel="noopener noreferrer">...</a> -->
|
||||
</template>
|
||||
```
|
||||
|
||||
:button-link[Open on StackBlitz]{href="https://stackblitz.com/github/nuxt/framework/tree/main/examples/nuxt-link?terminal=dev" blank}
|
||||
|
||||
## Props
|
||||
|
||||
- **to**: Any URL or a [route location object](https://router.vuejs.org/api/#routelocationraw) from Vue Router
|
||||
- **href**: An alias for `to`. If used with `to`, `href` will be ignored
|
||||
- **target**: A `target` attribute value to apply on the link
|
||||
- **rel**: A `rel` attribute value to apply on the link. Defaults to `"noopener noreferrer"` for external links.
|
||||
- **noRel**: If set to `true`, no `rel` attribute will be added to the link
|
||||
- **activeClass**: A class to apply on active links. Works the same as [Vue Router's `active-class` prop](https://router.vuejs.org/api/#active-class) on internal links. Defaults to Vue Router's default (`"router-link-active"`)
|
||||
- **exactActiveClass**: A class to apply on exact active links. Works the same as [Vue Router's `exact-active-class` prop](https://router.vuejs.org/api/#exact-active-class) on internal links. Defaults to Vue Router's default `"router-link-exact-active"`)
|
||||
- **replace**: Works the same as [Vue Router's `replace` prop](https://router.vuejs.org/api/#replace) on internal links
|
||||
- **ariaCurrentValue**: An `aria-current` attribute value to apply on exact active links. Works the same as [Vue Router's `aria-current-value` prop](https://router.vuejs.org/api/#aria-current-value) on internal links
|
||||
- **external**: Forces the link to be considered as external (`true`) or internal (`false`). This is helpful to handle edge-cases
|
||||
|
||||
::alert{icon=👉}
|
||||
Defaults can be overwriten, see [overwriting defaults](#overwriting-defaults) if you want to change them.
|
||||
::
|
||||
|
||||
## Overwriting defaults
|
||||
|
||||
You can overwrite `<NuxtLink>` defaults by creating your own link component using `defineNuxtLink`.
|
||||
|
||||
```js [components/MyNuxtLink.js]
|
||||
export default defineNuxtLink({
|
||||
name: 'MyNuxtLink',
|
||||
/* see signature below for more */
|
||||
})
|
||||
```
|
||||
|
||||
You can then use `<MyNuxtLink />` component as usual with your new defaults.
|
||||
|
||||
:button-link[Open on StackBlitz]{href="https://stackblitz.com/github/nuxt/framework/tree/main/examples/nuxt-link-pages?terminal=dev" blank}
|
||||
|
||||
### `defineNuxtLink` signature
|
||||
|
||||
```ts
|
||||
defineNuxtLink({
|
||||
componentName?: string;
|
||||
externalRelAttribute?: string;
|
||||
activeClass?: string;
|
||||
exactActiveClass?: string;
|
||||
}) => Component
|
||||
```
|
||||
|
||||
- **componentName**: A name for the defined `<NuxtLink>` component.- **externalRelAttribute**: A default `rel` attribute value applied on external links. Defaults to `"noopener noreferrer"`. Set it to `""` to disable
|
||||
- **activeClass**: A default class to apply on active links. Works the same as [Vue Router's `linkActiveClass` option](https://router.vuejs.org/api/#linkactiveclass). Defaults to Vue Router's default (`"router-link-active"`)
|
||||
- **exactActiveClass**: A default class to apply on exact active links. Works the same as [Vue Router's `linkExactActiveClass` option](https://router.vuejs.org/api/#linkexactactiveclass). Defaults to Vue Router's default (`"router-link-exact-active"`)
|
13
examples/nuxt-link/app.vue
Normal file
13
examples/nuxt-link/app.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<NuxtExampleLayout :show-tips="true" example="nuxt-link" class="example">
|
||||
<NuxtPage />
|
||||
</NuxtExampleLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.example a {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
padding: 1rem 10rem;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
6
examples/nuxt-link/components/MyNuxtLink.js
Normal file
6
examples/nuxt-link/components/MyNuxtLink.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default defineNuxtLink({
|
||||
componentName: 'MyNuxtLink',
|
||||
externalRelAttribute: '',
|
||||
activeClass: 'active',
|
||||
exactActiveClass: 'exact-active'
|
||||
})
|
7
examples/nuxt-link/nuxt.config.ts
Normal file
7
examples/nuxt-link/nuxt.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineNuxtConfig } from 'nuxt3'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxt/ui'
|
||||
]
|
||||
})
|
13
examples/nuxt-link/package.json
Normal file
13
examples/nuxt-link/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "example-nuxt-link",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxi build",
|
||||
"dev": "nuxi dev",
|
||||
"start": "nuxi preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/ui": "npm:@nuxt/ui-edge@latest",
|
||||
"nuxt3": "latest"
|
||||
}
|
||||
}
|
5
examples/nuxt-link/pages/about.vue
Normal file
5
examples/nuxt-link/pages/about.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLink to="/">
|
||||
Index page
|
||||
</NuxtLink>
|
||||
</template>
|
25
examples/nuxt-link/pages/index.vue
Normal file
25
examples/nuxt-link/pages/index.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLink to="/about">
|
||||
About page
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://nuxtjs.org">
|
||||
Nuxt website
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://twitter.com/nuxt_js" target="_blank">
|
||||
Nuxt Twitter with a blank target
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://discord.nuxtjs.org" target="_blank" rel="noopener">
|
||||
Nuxt Discord with a blank target and custom rel value
|
||||
</NuxtLink>
|
||||
<NuxtLink to="https://github.com/nuxt" no-rel>
|
||||
Nuxt GitHub without rel attribute
|
||||
</NuxtLink>
|
||||
<MyNuxtLink to="https://nuxtjs.org">
|
||||
Nuxt website with a custom link component with no default rel attribute
|
||||
</MyNuxtLink>
|
||||
<MyNuxtLink to="/">
|
||||
Index page with a custom link component with a custom active class
|
||||
</MyNuxtLink>
|
||||
</div>
|
||||
</template>
|
3
examples/nuxt-link/tsconfig.json
Normal file
3
examples/nuxt-link/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
3
packages/nuxt3/src/app/components/index.ts
Normal file
3
packages/nuxt3/src/app/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// defineNuxtLink
|
||||
export { defineNuxtLink } from './nuxt-link'
|
||||
export type { NuxtLinkOptions, NuxtLinkProps } from './nuxt-link'
|
186
packages/nuxt3/src/app/components/nuxt-link.ts
Normal file
186
packages/nuxt3/src/app/components/nuxt-link.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import { defineComponent, h, resolveComponent, PropType, computed, DefineComponent } from 'vue'
|
||||
import { RouteLocationRaw, Router } from 'vue-router'
|
||||
|
||||
import { useRouter } from '#app'
|
||||
|
||||
const firstNonUndefined = <T>(...args: T[]): T => args.find(arg => arg !== undefined)
|
||||
|
||||
const DEFAULT_EXTERNAL_REL_ATTRIBUTE = 'noopener noreferrer'
|
||||
|
||||
export type NuxtLinkOptions = {
|
||||
componentName?: string;
|
||||
externalRelAttribute?: string | null;
|
||||
activeClass?: string;
|
||||
exactActiveClass?: string;
|
||||
}
|
||||
|
||||
export type NuxtLinkProps = {
|
||||
// Routing
|
||||
to?: string | RouteLocationRaw;
|
||||
href?: string | RouteLocationRaw;
|
||||
external?: boolean;
|
||||
|
||||
// Attributes
|
||||
target?: string;
|
||||
rel?: string;
|
||||
noRel?: boolean;
|
||||
|
||||
// Styling
|
||||
activeClass?: string;
|
||||
exactActiveClass?: string;
|
||||
|
||||
// Vue Router's `<RouterLink>` additional props
|
||||
replace?: boolean;
|
||||
ariaCurrentValue?: string;
|
||||
};
|
||||
|
||||
export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
const componentName = options.componentName || 'NuxtLink'
|
||||
|
||||
const checkPropConflicts = (props: NuxtLinkProps, main: string, sub: string): void => {
|
||||
if (process.dev && props[main] !== undefined && props[sub] !== undefined) {
|
||||
console.warn(`[${componentName}] \`${main}\` and \`${sub}\` cannot be used together. \`${sub}\` will be ignored.`)
|
||||
}
|
||||
}
|
||||
|
||||
return defineComponent({
|
||||
name: componentName,
|
||||
props: {
|
||||
// Routing
|
||||
to: {
|
||||
type: [String, Object] as PropType<string | RouteLocationRaw>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
href: {
|
||||
type: [String, Object] as PropType<string | RouteLocationRaw>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Attributes
|
||||
target: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
rel: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
noRel: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Styling
|
||||
activeClass: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
exactActiveClass: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Vue Router's `<RouterLink>` additional props
|
||||
replace: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
ariaCurrentValue: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Edge cases handling
|
||||
external: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: undefined,
|
||||
required: false
|
||||
},
|
||||
|
||||
// Slot API
|
||||
custom: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: undefined,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
setup (props, { slots }) {
|
||||
const router = useRouter() as Router | undefined
|
||||
|
||||
// Resolving `to` value from `to` and `href` props
|
||||
const to = computed<string | RouteLocationRaw>(() => {
|
||||
checkPropConflicts(props, 'to', 'href')
|
||||
|
||||
return props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute)
|
||||
})
|
||||
|
||||
// Resolving link type
|
||||
const isExternal = computed<boolean>(() => {
|
||||
// External prop is explictly set
|
||||
if (props.external) {
|
||||
return true
|
||||
}
|
||||
|
||||
// When `target` prop is set, link is external
|
||||
if (props.target && props.target !== '_self') {
|
||||
return true
|
||||
}
|
||||
|
||||
// When `to` is a route object then it's an internal link
|
||||
if (typeof to.value === 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Directly check if `to` is an external URL with Regex
|
||||
// Regex101 expression: {@link https://regex101.com/r/1y7iod/1}
|
||||
// TODO: Use `ufo.hasProtocol` when issue fixed https://github.com/unjs/ufo/issues/45
|
||||
return !/^\/(?!\/)/.test(to.value)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (!isExternal.value) {
|
||||
// Internal link
|
||||
return h(
|
||||
resolveComponent('RouterLink'),
|
||||
{
|
||||
to: to.value,
|
||||
activeClass: props.activeClass || options.activeClass,
|
||||
exactActiveClass: props.exactActiveClass || options.exactActiveClass,
|
||||
replace: props.replace,
|
||||
ariaCurrentValue: props.ariaCurrentValue
|
||||
},
|
||||
// TODO: Slot API
|
||||
slots.default
|
||||
)
|
||||
}
|
||||
|
||||
// Resolves `to` value if it's a route location object
|
||||
// converts `'''` to `null` to prevent the attribute from being added as empty (`href=""`)
|
||||
const href = typeof to.value === 'object' ? router.resolve(to.value)?.href ?? null : to.value || null
|
||||
|
||||
// Resolves `target` value
|
||||
const target = props.target || null
|
||||
|
||||
// Resolves `rel`
|
||||
checkPropConflicts(props, 'noRel', 'rel')
|
||||
const rel = props.noRel
|
||||
? null
|
||||
// converts `""` to `null` to prevent the attribute from being added as empty (`rel=""`)
|
||||
: firstNonUndefined<string | null>(props.rel, options.externalRelAttribute, DEFAULT_EXTERNAL_REL_ATTRIBUTE) || null
|
||||
|
||||
return h('a', { href, rel, target }, slots.default())
|
||||
}
|
||||
}
|
||||
}) as unknown as DefineComponent<NuxtLinkProps>
|
||||
}
|
||||
|
||||
export default defineNuxtLink({ componentName: 'NuxtLink' })
|
@ -2,6 +2,8 @@
|
||||
|
||||
export * from './nuxt'
|
||||
export * from './composables'
|
||||
export * from './components'
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
export type { PageMeta } from '../pages/runtime'
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DefineComponent, reactive, h } from 'vue'
|
||||
import { reactive, h } from 'vue'
|
||||
import { parseURL, parseQuery } from 'ufo'
|
||||
import { NuxtApp } from '@nuxt/schema'
|
||||
import { createError } from 'h3'
|
||||
@ -6,12 +6,6 @@ import { defineNuxtPlugin } from '..'
|
||||
import { callWithNuxt } from '../nuxt'
|
||||
import { clearError, throwError } from '#app'
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
NuxtLink: DefineComponent<{ to: String }>
|
||||
}
|
||||
}
|
||||
|
||||
interface Route {
|
||||
/** Percentage encoded pathname section of the URL. */
|
||||
path: string;
|
||||
@ -34,7 +28,11 @@ interface Route {
|
||||
meta: Record<string, any>;
|
||||
}
|
||||
|
||||
function getRouteFromPath (fullPath: string) {
|
||||
function getRouteFromPath (fullPath: string | Record<string, unknown>) {
|
||||
if (typeof fullPath === 'object') {
|
||||
throw new TypeError('[nuxt] Route location object cannot be resolved when vue-router is disabled (no pages).')
|
||||
}
|
||||
|
||||
const url = parseURL(fullPath.toString())
|
||||
return {
|
||||
path: url.pathname,
|
||||
@ -46,7 +44,8 @@ function getRouteFromPath (fullPath: string) {
|
||||
name: undefined,
|
||||
matched: [],
|
||||
redirectedFrom: undefined,
|
||||
meta: {}
|
||||
meta: {},
|
||||
href: fullPath
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,7 +79,7 @@ interface Router {
|
||||
afterEach: (guard: RouterHooks['navigate:after']) => () => void
|
||||
onError: (handler: RouterHooks['error']) => () => void
|
||||
// Routes
|
||||
resolve: (url: string) => Route
|
||||
resolve: (url: string | Record<string, unknown>) => Route
|
||||
addRoute: (parentName: string, route: Route) => void
|
||||
getRoutes: () => any[]
|
||||
hasRoute: (name: string) => boolean
|
||||
@ -174,6 +173,12 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => {
|
||||
}
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.component('RouterLink', {
|
||||
functional: true,
|
||||
props: { to: String },
|
||||
setup: (props, { slots }) => () => h('a', { href: props.to, onClick: (e) => { e.preventDefault(); router.push(props.to) } }, slots)
|
||||
})
|
||||
|
||||
if (process.client) {
|
||||
window.addEventListener('popstate', (event) => {
|
||||
const location = (event.target as Window).location
|
||||
@ -214,12 +219,6 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => {
|
||||
delete nuxtApp._processingMiddleware
|
||||
})
|
||||
|
||||
nuxtApp.vueApp.component('NuxtLink', {
|
||||
functional: true,
|
||||
props: { to: String },
|
||||
setup: (props, { slots }) => () => h('a', { href: props.to, onClick: (e) => { e.preventDefault(); router.push(props.to) } }, slots)
|
||||
})
|
||||
|
||||
if (process.server) {
|
||||
nuxtApp.hooks.hookOnce('app:created', async () => {
|
||||
await router.push(nuxtApp.ssrContext.url)
|
||||
|
@ -40,7 +40,8 @@ export const appPreset = defineUnimportPreset({
|
||||
'addRouteMiddleware',
|
||||
'throwError',
|
||||
'clearError',
|
||||
'useError'
|
||||
'useError',
|
||||
'defineNuxtLink'
|
||||
]
|
||||
})
|
||||
|
||||
|
@ -90,6 +90,12 @@ async function initNuxt (nuxt: Nuxt) {
|
||||
filePath: resolve(nuxt.options.appDir, 'components/client-only')
|
||||
})
|
||||
|
||||
// Add <NuxtLink>
|
||||
addComponent({
|
||||
name: 'NuxtLink',
|
||||
filePath: resolve(nuxt.options.appDir, 'components/nuxt-link')
|
||||
})
|
||||
|
||||
for (const m of modulesToInstall) {
|
||||
if (Array.isArray(m)) {
|
||||
await installModule(m[0], m[1])
|
||||
|
@ -3,7 +3,6 @@ import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
createMemoryHistory,
|
||||
RouterLink,
|
||||
NavigationGuard
|
||||
} from 'vue-router'
|
||||
import { createError } from 'h3'
|
||||
@ -17,7 +16,6 @@ import { globalMiddleware, namedMiddleware } from '#build/middleware'
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
NuxtPage: typeof NuxtPage
|
||||
NuxtLink: typeof RouterLink
|
||||
/** @deprecated */
|
||||
NuxtNestedPage: typeof NuxtPage
|
||||
/** @deprecated */
|
||||
@ -27,7 +25,6 @@ declare module 'vue' {
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('NuxtPage', NuxtPage)
|
||||
nuxtApp.vueApp.component('NuxtLink', RouterLink)
|
||||
// TODO: remove before release - present for backwards compatibility & intentionally undocumented
|
||||
nuxtApp.vueApp.component('NuxtNestedPage', NuxtPage)
|
||||
nuxtApp.vueApp.component('NuxtChild', NuxtPage)
|
||||
|
200
packages/nuxt3/test/nuxt-link.test.ts
Normal file
200
packages/nuxt3/test/nuxt-link.test.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { expect, describe, it, vi } from 'vitest'
|
||||
import { RouteLocationRaw } from 'vue-router'
|
||||
import { NuxtLinkOptions, NuxtLinkProps, defineNuxtLink } from '../src/app/components/nuxt-link'
|
||||
|
||||
// Mocks `h()`
|
||||
vi.mock('vue', async () => {
|
||||
const vue: Record<string, unknown> = await vi.importActual('vue')
|
||||
return {
|
||||
...vue,
|
||||
resolveComponent: (name: string) => name,
|
||||
h: (...args) => args
|
||||
}
|
||||
})
|
||||
|
||||
// Mocks Nuxt `useRouter()`
|
||||
vi.mock('#app', () => ({
|
||||
useRouter: () => ({ resolve: ({ to }: { to: string }) => ({ href: to }) })
|
||||
}))
|
||||
|
||||
// Helpers for test lisibility
|
||||
const EXTERNAL = 'a'
|
||||
const INTERNAL = 'RouterLink'
|
||||
|
||||
// Renders a `<NuxtLink />`
|
||||
const nuxtLink = (
|
||||
props: NuxtLinkProps = {},
|
||||
NuxtLinkOptions: Partial<NuxtLinkOptions> = {}
|
||||
): { type: string, props: Record<string, unknown>, slots: unknown } => {
|
||||
const component = defineNuxtLink({ componentName: 'NuxtLink', ...NuxtLinkOptions })
|
||||
|
||||
const [type, _props, slots] = (component.setup as unknown as (props: NuxtLinkProps, context: { slots: Record<string, () => unknown> }) =>
|
||||
() => [string, Record<string, unknown>, unknown])(props, { slots: { default: () => null } })()
|
||||
|
||||
return { type, props: _props, slots }
|
||||
}
|
||||
|
||||
describe('nuxt-link:to', () => {
|
||||
it('renders link with `to` prop', () => {
|
||||
expect(nuxtLink({ to: '/to' }).props.to).toBe('/to')
|
||||
})
|
||||
|
||||
it('renders link with `href` prop', () => {
|
||||
expect(nuxtLink({ href: '/href' }).props.to).toBe('/href')
|
||||
})
|
||||
|
||||
it('renders link with `to` prop and warns about `href` prop conflict', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn())
|
||||
|
||||
expect(nuxtLink({ to: '/to', href: '/href' }).props.to).toBe('/to')
|
||||
// TODO: Uncomment when `dev` mode for tests is available
|
||||
// expect(consoleWarnSpy).toHaveBeenCalledOnce()
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('defaults to `null`', () => {
|
||||
expect(nuxtLink().props.href).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nuxt-link:isExternal', () => {
|
||||
it('returns based on `to` value', () => {
|
||||
// Internal
|
||||
expect(nuxtLink({ to: '/foo' }).type).toBe(INTERNAL)
|
||||
expect(nuxtLink({ to: '/foo/bar' }).type).toBe(INTERNAL)
|
||||
expect(nuxtLink({ to: '/foo/bar?baz=qux' }).type).toBe(INTERNAL)
|
||||
|
||||
// External
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org' }).type).toBe(EXTERNAL)
|
||||
expect(nuxtLink({ to: '//nuxtjs.org' }).type).toBe(EXTERNAL)
|
||||
expect(nuxtLink({ to: 'tel:0123456789' }).type).toBe(EXTERNAL)
|
||||
expect(nuxtLink({ to: 'mailto:hello@nuxtlabs.com' }).type).toBe(EXTERNAL)
|
||||
})
|
||||
|
||||
it('returns `false` when `to` is a route location object', () => {
|
||||
expect(nuxtLink({ to: { to: '/to' } as RouteLocationRaw }).type).toBe(INTERNAL)
|
||||
})
|
||||
|
||||
it('honors `external` prop', () => {
|
||||
expect(nuxtLink({ to: '/to', external: true }).type).toBe(EXTERNAL)
|
||||
expect(nuxtLink({ to: '/to', external: false }).type).toBe(INTERNAL)
|
||||
})
|
||||
|
||||
it('returns `true` when using the `target` prop', () => {
|
||||
expect(nuxtLink({ to: '/foo', target: '_blank' }).type).toBe(EXTERNAL)
|
||||
expect(nuxtLink({ to: '/foo/bar', target: '_blank' }).type).toBe(EXTERNAL)
|
||||
expect(nuxtLink({ to: '/foo/bar?baz=qux', target: '_blank' }).type).toBe(EXTERNAL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nuxt-link:propsOrAttributes', () => {
|
||||
describe('`isExternal` is `true`', () => {
|
||||
describe('href', () => {
|
||||
it('forwards `to` value', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org' }).props.href).toBe('https://nuxtjs.org')
|
||||
})
|
||||
|
||||
it('resolves route location object', () => {
|
||||
expect(nuxtLink({ to: { to: '/to' } as RouteLocationRaw, external: true }).props.href).toBe('/to')
|
||||
})
|
||||
})
|
||||
|
||||
describe('target', () => {
|
||||
it('forwards `target` prop', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', target: '_blank' }).props.target).toBe('_blank')
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', target: null }).props.target).toBe(null)
|
||||
})
|
||||
|
||||
it('defaults to `null`', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org' }).props.target).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rel', () => {
|
||||
it('uses framework\'s default', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org' }).props.rel).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('uses user\'s default', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org' }, { externalRelAttribute: 'foo' }).props.rel).toBe('foo')
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org' }, { externalRelAttribute: null }).props.rel).toBe(null)
|
||||
})
|
||||
|
||||
it('uses and favors `rel` prop', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', rel: 'foo' }).props.rel).toBe('foo')
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', rel: 'foo' }, { externalRelAttribute: 'bar' }).props.rel).toBe('foo')
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', rel: null }, { externalRelAttribute: 'bar' }).props.rel).toBe(null)
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', rel: '' }, { externalRelAttribute: 'bar' }).props.rel).toBe(null)
|
||||
})
|
||||
|
||||
it('honors `noRel` prop', () => {
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', noRel: true }).props.rel).toBe(null)
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', noRel: false }).props.rel).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('honors `noRel` prop and warns about `rel` prop conflict', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn())
|
||||
|
||||
expect(nuxtLink({ to: 'https://nuxtjs.org', noRel: true, rel: 'foo' }).props.rel).toBe(null)
|
||||
// TODO: Uncomment when `dev` mode for tests is available
|
||||
// expect(consoleWarnSpy).toHaveBeenCalledOnce()
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('`isExternal` is `false`', () => {
|
||||
describe('to', () => {
|
||||
it('forwards `to` prop', () => {
|
||||
expect(nuxtLink({ to: '/to' }).props.to).toBe('/to')
|
||||
expect(nuxtLink({ to: { to: '/to' } as RouteLocationRaw }).props.to).toEqual({ to: '/to' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('activeClass', () => {
|
||||
it('uses framework\'s default', () => {
|
||||
expect(nuxtLink({ to: '/to' }).props.activeClass).toBe(undefined)
|
||||
})
|
||||
|
||||
it('uses user\'s default', () => {
|
||||
expect(nuxtLink({ to: '/to' }, { activeClass: 'activeClass' }).props.activeClass).toBe('activeClass')
|
||||
})
|
||||
|
||||
it('uses and favors `activeClass` prop', () => {
|
||||
expect(nuxtLink({ to: '/to', activeClass: 'propActiveClass' }).props.activeClass).toBe('propActiveClass')
|
||||
expect(nuxtLink({ to: '/to', activeClass: 'propActiveClass' }, { activeClass: 'activeClass' }).props.activeClass).toBe('propActiveClass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exactActiveClass', () => {
|
||||
it('uses framework\'s default', () => {
|
||||
expect(nuxtLink({ to: '/to' }).props.exactActiveClass).toBe(undefined)
|
||||
})
|
||||
|
||||
it('uses user\'s default', () => {
|
||||
expect(nuxtLink({ to: '/to' }, { exactActiveClass: 'exactActiveClass' }).props.exactActiveClass).toBe('exactActiveClass')
|
||||
})
|
||||
|
||||
it('uses and favors `exactActiveClass` prop', () => {
|
||||
expect(nuxtLink({ to: '/to', exactActiveClass: 'propExactActiveClass' }).props.exactActiveClass).toBe('propExactActiveClass')
|
||||
expect(nuxtLink({ to: '/to', exactActiveClass: 'propExactActiveClass' }, { exactActiveClass: 'exactActiveClass' }).props.exactActiveClass).toBe('propExactActiveClass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replace', () => {
|
||||
it('forwards `replace` prop', () => {
|
||||
expect(nuxtLink({ to: '/to', replace: true }).props.replace).toBe(true)
|
||||
expect(nuxtLink({ to: '/to', replace: false }).props.replace).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ariaCurrentValue', () => {
|
||||
it('forwards `ariaCurrentValue` prop', () => {
|
||||
expect(nuxtLink({ to: '/to', ariaCurrentValue: 'page' }).props.ariaCurrentValue).toBe('page')
|
||||
expect(nuxtLink({ to: '/to', ariaCurrentValue: 'step' }).props.ariaCurrentValue).toBe('step')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@ -3,6 +3,7 @@ import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
alias: {
|
||||
'#app': resolve('./packages/nuxt3/src/app/index.ts'),
|
||||
'@nuxt/test-utils': resolve('./packages/test-utils/src/index.ts')
|
||||
}
|
||||
})
|
||||
|
@ -10648,6 +10648,15 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"example-nuxt-link@workspace:examples/nuxt-link":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "example-nuxt-link@workspace:examples/nuxt-link"
|
||||
dependencies:
|
||||
"@nuxt/ui": "npm:@nuxt/ui-edge@latest"
|
||||
nuxt3: latest
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"example-use-async-data@workspace:examples/use-async-data":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "example-use-async-data@workspace:examples/use-async-data"
|
||||
|
Loading…
Reference in New Issue
Block a user