Skip to content

Commit a9ed10d

Browse files
committed
fix(Link): ensure consistency across Nuxt, Vue and Inertia
Resolves #5012
1 parent 5f30ccf commit a9ed10d

15 files changed

+296
-232
lines changed

src/runtime/inertia/components/Link.vue

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export interface LinkProps extends Partial<Omit<InertiaLinkProps, 'href' | 'onCl
3434
* A rel attribute value to apply on the link. Defaults to "noopener noreferrer" for external links.
3535
*/
3636
rel?: 'noopener' | 'noreferrer' | 'nofollow' | 'sponsored' | 'ugc' | (string & {}) | null
37+
/**
38+
* If set to true, no rel attribute will be added to the link
39+
*/
40+
noRel?: boolean
3741
/**
3842
* Value passed to the attribute `aria-current` when the link is exact active.
3943
*
@@ -72,32 +76,32 @@ import { usePage } from '@inertiajs/vue3'
7276
import { hasProtocol } from 'ufo'
7377
import { useAppConfig } from '#imports'
7478
import { tv } from '../../utils/tv'
79+
import { mergeClasses } from '../../utils'
7580
import ULinkBase from '../../components/LinkBase.vue'
7681
7782
defineOptions({ inheritAttrs: false })
7883
7984
const props = withDefaults(defineProps<LinkProps>(), {
8085
as: 'button',
8186
type: 'button',
82-
active: undefined,
83-
activeClass: '',
84-
inactiveClass: ''
87+
ariaCurrentValue: 'page',
88+
active: undefined
8589
})
8690
defineSlots<LinkSlots>()
8791
8892
const page = usePage()
8993
9094
const appConfig = useAppConfig() as Link['AppConfig']
9195
92-
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'activeClass', 'inactiveClass', 'to', 'href', 'raw', 'custom', 'class'))
96+
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'activeClass', 'inactiveClass', 'to', 'href', 'raw', 'custom', 'class', 'noRel'))
9397
9498
const ui = computed(() => tv({
9599
extend: tv(theme),
96100
...defu({
97101
variants: {
98102
active: {
99-
true: props.activeClass,
100-
false: props.inactiveClass
103+
true: mergeClasses(appConfig.ui?.link?.variants?.active?.true, props.activeClass),
104+
false: mergeClasses(appConfig.ui?.link?.variants?.active?.false, props.inactiveClass)
101105
}
102106
}
103107
}, appConfig.ui?.link || {})
@@ -121,6 +125,27 @@ const isExternal = computed(() => {
121125
return typeof href.value === 'string' && hasProtocol(href.value, { acceptRelative: true })
122126
})
123127
128+
const hasTarget = computed(() => !!props.target && props.target !== '_self')
129+
130+
const rel = computed(() => {
131+
// If noRel is explicitly set, return null
132+
if (props.noRel) {
133+
return null
134+
}
135+
136+
// If rel is explicitly set, use it
137+
if (props.rel !== undefined) {
138+
return props.rel || null
139+
}
140+
141+
// Default to "noopener noreferrer" for external links or links with target
142+
if (isExternal.value || hasTarget.value) {
143+
return 'noopener noreferrer'
144+
}
145+
146+
return null
147+
})
148+
124149
const isLinkActive = computed(() => {
125150
if (props.active !== undefined) {
126151
return props.active
@@ -162,6 +187,8 @@ const linkClass = computed(() => {
162187
type,
163188
disabled,
164189
href,
190+
rel,
191+
target,
165192
active: isLinkActive,
166193
isExternal
167194
}"
@@ -176,6 +203,8 @@ const linkClass = computed(() => {
176203
type,
177204
disabled,
178205
href,
206+
rel,
207+
target,
179208
isExternal
180209
}"
181210
:class="linkClass"

src/runtime/inertia/components/LinkBase.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface LinkBaseProps {
88
onClick?: ((e: MouseEvent) => void | Promise<void>) | Array<((e: MouseEvent) => void | Promise<void>)>
99
href?: string
1010
target?: LinkProps['target']
11+
rel?: LinkProps['rel']
1112
active?: boolean
1213
isExternal?: boolean
1314
}
@@ -44,7 +45,8 @@ function onClickWrapper(e: MouseEvent) {
4445
v-if="!!href && !isExternal && !disabled"
4546
:href="href"
4647
v-bind="{
47-
target: target || (isExternal ? '_blank' : undefined),
48+
rel,
49+
target,
4850
...$attrs
4951
}"
5052
@click="onClickWrapper"
@@ -59,7 +61,8 @@ function onClickWrapper(e: MouseEvent) {
5961
'aria-disabled': disabled ? 'true' : undefined,
6062
'role': disabled ? 'link' : undefined,
6163
'tabindex': disabled ? -1 : undefined,
62-
'target': target || (isExternal ? '_blank' : undefined),
64+
'rel': rel,
65+
'target': target,
6366
...$attrs
6467
} : as === 'button' ? {
6568
as,

src/runtime/vue/components/Link.vue

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export interface LinkProps extends Partial<Omit<RouterLinkProps, 'custom'>>, /**
2929
* A rel attribute value to apply on the link. Defaults to "noopener noreferrer" for external links.
3030
*/
3131
rel?: 'noopener' | 'noreferrer' | 'nofollow' | 'sponsored' | 'ugc' | (string & {}) | null
32+
/**
33+
* If set to true, no rel attribute will be added to the link
34+
*/
35+
noRel?: boolean
3236
/**
3337
* The type of the button when not a link.
3438
* @defaultValue 'button'
@@ -66,6 +70,7 @@ import { hasProtocol } from 'ufo'
6670
import { useRoute, RouterLink } from 'vue-router'
6771
import { useAppConfig } from '#imports'
6872
import { tv } from '../../utils/tv'
73+
import { mergeClasses } from '../../utils'
6974
import { isPartiallyEqual } from '../../utils/link'
7075
import ULinkBase from '../../components/LinkBase.vue'
7176
@@ -75,25 +80,23 @@ const props = withDefaults(defineProps<LinkProps>(), {
7580
as: 'button',
7681
type: 'button',
7782
ariaCurrentValue: 'page',
78-
active: undefined,
79-
activeClass: '',
80-
inactiveClass: ''
83+
active: undefined
8184
})
8285
defineSlots<LinkSlots>()
8386
8487
const route = useRoute()
8588
8689
const appConfig = useAppConfig() as Link['AppConfig']
8790
88-
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass', 'to', 'href', 'raw', 'custom', 'class'))
91+
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass', 'to', 'href', 'raw', 'custom', 'class', 'noRel'))
8992
9093
const ui = computed(() => tv({
9194
extend: tv(theme),
9295
...defu({
9396
variants: {
9497
active: {
95-
true: props.activeClass,
96-
false: props.inactiveClass
98+
true: mergeClasses(appConfig.ui?.link?.variants?.active?.true, props.activeClass),
99+
false: mergeClasses(appConfig.ui?.link?.variants?.active?.false, props.inactiveClass)
97100
}
98101
}
99102
}, appConfig.ui?.link || {})
@@ -113,6 +116,27 @@ const isExternal = computed(() => {
113116
return typeof to.value === 'string' && hasProtocol(to.value, { acceptRelative: true })
114117
})
115118
119+
const hasTarget = computed(() => !!props.target && props.target !== '_self')
120+
121+
const rel = computed(() => {
122+
// If noRel is explicitly set, return null
123+
if (props.noRel) {
124+
return null
125+
}
126+
127+
// If rel is explicitly set, use it
128+
if (props.rel !== undefined) {
129+
return props.rel || null
130+
}
131+
132+
// Default to "noopener noreferrer" for external links or links with target
133+
if (isExternal.value || hasTarget.value) {
134+
return 'noopener noreferrer'
135+
}
136+
137+
return null
138+
})
139+
116140
function isLinkActive({ route: linkRoute, isActive, isExactActive }: any) {
117141
if (props.active !== undefined) {
118142
return props.active
@@ -168,6 +192,9 @@ function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
168192
disabled,
169193
href,
170194
navigate,
195+
rel,
196+
target,
197+
isExternal,
171198
active: isLinkActive({ route: linkRoute, isActive, isExactActive })
172199
}"
173200
/>
@@ -181,7 +208,10 @@ function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
181208
type,
182209
disabled,
183210
href,
184-
navigate
211+
navigate,
212+
rel,
213+
target,
214+
isExternal
185215
}"
186216
:class="resolveLinkClass({ route: linkRoute, isActive, isExactActive })"
187217
>
@@ -199,7 +229,8 @@ function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
199229
type,
200230
disabled,
201231
href: to,
202-
target: isExternal ? '_blank' : undefined,
232+
rel,
233+
target,
203234
active,
204235
isExternal
205236
}"
@@ -213,7 +244,8 @@ function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
213244
type,
214245
disabled,
215246
href: (to as string),
216-
target: isExternal ? '_blank' : undefined,
247+
rel,
248+
target,
217249
isExternal
218250
}"
219251
:class="resolveLinkClass()"

test/components/__snapshots__/Banner-vue.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ exports[`Banner > renders with neutral color correctly 1`] = `
185185
`;
186186

187187
exports[`Banner > renders with target correctly 1`] = `
188-
"<div data-v-0fc735df="" class="banner relative z-50 w-full transition-colors bg-primary hover:bg-primary/90" data-slot="root"><a href="https://nuxt.com" tabindex="-1" target="_blank" class="focus:outline-none"><span data-v-0fc735df="" class="absolute inset-0" aria-hidden="true"></span></a>
188+
"<div data-v-0fc735df="" class="banner relative z-50 w-full transition-colors bg-primary hover:bg-primary/90" data-slot="root"><a href="https://nuxt.com" tabindex="-1" rel="noopener noreferrer" target="_blank" class="focus:outline-none"><span data-v-0fc735df="" class="absolute inset-0" aria-hidden="true"></span></a>
189189
<div data-v-0fc735df="" class="w-full max-w-(--ui-container) mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between gap-3 h-12" data-slot="container">
190190
<div data-v-0fc735df="" data-slot="left" class="hidden lg:flex-1 lg:flex lg:items-center"></div>
191191
<div data-v-0fc735df="" data-slot="center" class="flex items-center gap-1.5 min-w-0">

0 commit comments

Comments
 (0)