Link
A link goes somewhere. That is what separates it from a Button, which does something — and it is worth holding to, because the browser gives a real anchor a middle-click, a context menu and a copyable address that no button can offer.
import { Link, LinkIcon } from "ropav";link-basic.vue
<script setup lang="ts">
import { Link, LinkIcon } from "ropav";
</script>
<template>
<Link href="https://github.com/daopk/ropav" rel="noopener noreferrer" target="_blank">
Ropav on GitHub
<LinkIcon />
</Link>
<Link href="#" is-disabled>
Not available yet
<LinkIcon />
</Link>
</template>LinkIcon marks the link's icon, at either end of the label. Left empty it draws an external-link glyph and makes room for it; fill its slot with your own and the extra room is dropped, because a glyph of your own does not need it.
The underline is a class decision rather than a prop: by default it appears on hover and while the link is pressed, class="underline" keeps it there, and class="no-underline" takes it away.
When it is not an anchor
A link with no href, and a disabled link, render as a <span role="link"> instead — there is no destination for the browser to follow, and pretending otherwise leaves a stray click or a middle-click going somewhere. The consequence is that a DOM click is not a reliable signal here: a span never produces one from Enter. Listen for press instead, which every activation path reaches.
Routing
RouterProvider hands the link your application's navigate, and from then on a click is intercepted rather than followed. It stands aside for every click the browser must keep — a modifier held, another origin, a download, another target — so those keep working without being listed anywhere.
Route: /inbox — no reload, and no router in the library.
link-router.vue
<script setup lang="ts">
import { Link, RouterProvider } from "ropav";
import { shallowRef } from "vue";
// Stands in for the application's router. In a vue-router app `navigate` is
// `(href, options) => router.push({path: href, ...options})`.
const path = shallowRef("/inbox");
const isCurrent = (href: string) => href === path.value;
const navigate = (href: string) => {
path.value = href;
};
</script>
<template>
<RouterProvider :is-current="isCurrent" :navigate="navigate">
<div class="stack">
<nav class="nav">
<Link
v-for="href in ['/inbox', '/drafts', '/sent']"
:key="href"
aria-current="auto"
class="tab"
:href="href"
>
{{ href.slice(1) }}
</Link>
</nav>
<p class="note">
Route: <code>{{ path }}</code> — no reload, and no router in the library.
</p>
</div>
</RouterProvider>
</template>
<style scoped>
.stack {
display: flex;
flex-direction: column;
gap: calc(var(--rp-spacing) * 3);
}
.nav {
display: flex;
align-items: center;
gap: calc(var(--rp-spacing) * 4);
}
.nav .tab {
text-decoration-line: none;
&[data-current="true"] {
font-weight: var(--rp-font-weight-semibold);
}
&[data-current="true"] {
text-decoration-line: underline;
}
}
.stack .note {
color: var(--rp-muted);
font-size: var(--rp-text-sm);
line-height: var(--rp-text-sm--line-height);
}
</style>aria-current="auto" is the opt-in: it asks the router whether this link addresses the route showing now, and resolves to "page" when it does. Asking is per link, so a link that names its own aria-current, or names none, is left alone. The matching rule is yours — isCurrent is where you decide whether a section link counts as current on its child pages.
For a router mounted under a base path or running in hash mode, resolveHref rewrites the address the anchor carries. The router itself is still handed the href as the link declared it, because that is the path it wants back.
Accessibility
aria-current="auto"never reaches the DOM. It is resolved before rendering, so the attribute is either a real ARIA token or absent.data-currentis rendered alongside it, which is what gives you a selector for the current link's styling without a class to keep in step.- A tab index is written even on a real anchor, because Safari does not focus one without it. A disabled link gets none.
- A link that opens in a new tab should say so — the icon is not an accessible name, and
rel="noopener noreferrer"belongs withtarget="_blank"either way.
API
Link
| Prop | Type | Default | Description |
|---|---|---|---|
ariaCurrent | LinkCurrent | — | Marks the link as the one for the current page. "auto" asks the router instead. |
ariaDescribedby | string | — | |
ariaLabel | string | — | |
ariaLabelledby | string | — | |
download | string | boolean | — | |
href | string | — | Where the link goes. Without one the link renders as a span with a link role. |
hrefLang | string | — | |
isDisabled | boolean | — | Whether the link is disabled. A disabled link renders as a span rather than an anchor, so there is no href for the browser to follow, matching React. |
ping | string | — | |
referrerPolicy | "" | "no-referrer" | "no-referrer-when-downgrade" | "origin" | "origin-when-cross-origin" | "same-origin" | "strict-origin" | "strict-origin-when-cross-origin" | "unsafe-url" | — | |
rel | string | — | |
routerOptions | unknown | — | Passed through to the router's navigate alongside the href, for whatever options the application's router accepts. Ignored without a RouterProvider above. |
target | string | — |
The default slot receives:
| Slot prop | Type |
|---|---|
isCurrent | boolean |
isDisabled | boolean |
isFocused | boolean |
isFocusVisible | boolean |
isHovered | boolean |
isPressed | boolean |
| Event | Payload |
|---|---|
press | PressEvent |
LinkIcon
Slots: default.