GrLink
Берут, когда нужен переход.
Когда брать
- нужен переход — ссылка меняет адрес, и открытие в новой вкладке обязано работать;
- роутер свой —
asподменяет корневой тег наRouterLinkилиNuxtLinkбез обёртки; - ссылка ведёт наружу —
externalдобавляет значок и подпись «в новой вкладке» для скринридера; - ссылка живёт в тексте —
underlineиtoneдержат её узнаваемой внутри абзаца.
Когда взять другое
| Нужно | Берите |
|---|---|
| Действие меняет состояние, а не адрес | GrButton |
| Ссылка выглядит кнопкой | GrButton с as/href |
| Список переходов | GrSidebar / GrDropdownMenu |
| Путь до текущей страницы | GrBreadcrumbs |
Кнопка меняет состояние, ссылка меняет адрес. Подменять одно другим ради внешности нельзя: у ссылки работают средняя кнопка мыши, контекстное меню и «открыть в новой вкладке», у кнопки — нет, и пользователь замечает это первым.
Корневой тег
| Условие | Что рендерится |
|---|---|
задан as и не disabled | <component :is="as"> — RouterLink, Link от Inertia, любой свой |
задан href и не disabled | <a> |
остальное, включая disabled | <span> |
Отключённая ссылка намеренно перестаёт быть <a>: так она не кликабельна и не
участвует в порядке табуляции. Внешние CSS-селекторы это должны учитывать.
Атрибуты роутера (method, replace, preserve-scroll) проходят через
fallthrough — компонент их не перечисляет.
Новая вкладка объявляется
Ссылка, открывающаяся в новой вкладке, получает две вещи:
- иконку внешней ссылки — видимый признак;
- скрытый суффикс «(откроется в новой вкладке)» — предупреждение о смене контекста (WCAG 3.2.5), без него переход в новую вкладку для незрячего пользователя происходит без предупреждения.
Условие — фактическое поведение ссылки (target="_blank"), а не проп
external: target снаружи даёт ровно тот же сюрприз. По той же причине
компонент выставляет rel="noopener noreferrer" любой ссылке с _blank, а не
только external.
<GrLink href="https://example.com" external>
Документация
</GrLink>
<GrLink href="https://example.com" target="_blank">
То же самое
</GrLink>
<!-- Иконку можно выключить или, наоборот, включить внутренней ссылке. -->
<GrLink href="https://example.com" external :external-icon="false">
Без иконки
</GrLink>
<GrLink href="/inner" external-icon>
С иконкой
</GrLink>
newTabLabel перекрывает текст подсказки, ключ локали — gr.link.opensInNewTab.
ariaLabel и подсказка
aria-label перекрывает содержимое элемента целиком — вместе со скрытым
суффиксом. Поэтому, если имя задано вручную, подсказка дописывается к нему:
aria-label="Документация" у внешней ссылки превращается в
«Документация, откроется в новой вкладке», а отдельный sr-only-суффикс не
рендерится, чтобы диктор не прочитал его дважды.
Цвет: `tone` × `variant`
Оси ортогональны. tone — цвет из общей палитры (primary, neutral,
success, warning, danger, info, slate, azure), variant — уровень
акцента: default (окрашен в покое) или muted (приглушён, окрашивается при
наведении).
Цвет прокидывается CSS-переменными (--gr-link-color, -hover, -active), а
не классами на каждую комбинацию: 8 тонов × 3 состояния дали бы class-эксплозию,
а safelist остаётся крошечным.
Тона берутся из ролей -text, а не из насыщенных --gr-{tone}: ссылка — это
текст на фоне страницы, а --gr-success на --gr-bg даёт 2.2:1.
Подчёркивание и disabled
underline: auto (появляется на hover), always, none.
Отключённая ссылка гасится цветом --gr-muted-fg, а не opacity: прозрачность
разбавляет выверенный на AA токен и роняет контраст ниже нормы.
Playground 10
Загружается…
<GrLink />Установка
npm i @feugene/granularityИмпорт
import { GrLink } from '@feugene/granularity/components/GrLink'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "primary" | Семантический цвет ссылки из палитры `GrTone`. |
variant | GrLinkVariant | undefined | "default" | Уровень акцента: `default` (окрашен) или `muted` (приглушён, акцент на hover). |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
as | string | Component | undefined | undefined | Кастомный корневой тег/компонент. Если передан и компонент не `disabled` — рендерится через `<component :is="as">`. Игнорируется при `disabled`. |
href | string | undefined | undefined | — |
target | string | undefined | undefined | — |
rel | string | undefined | undefined | — |
external | boolean | undefined | false | — |
underline | GrLinkUnderline | undefined | "auto" | — |
externalIcon | boolean | undefined | undefined | Иконка внешней ссылки. По умолчанию показывается у любой ссылки, которая открывается в новой вкладке, — не только при `external`. |
newTabLabel | string | undefined | undefined | i18n: скрытая подсказка «откроется в новой вкладке». |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Текст ссылки. |
Примеры 4
Интерактивный конструктор ссылки
Соберите GrLink под ваш сценарий: переключайте tone, underline, size, навигационные атрибуты и сразу смотрите итоговый snippet.
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
GrFormField,
GrInput,
GrLink,
GrRadioGroup,
GrSelect,
GrSwitch,
type GrLinkSize,
type GrLinkTone,
type GrLinkUnderline,
type GrLinkVariant,
} from '@feugene/granularity'
import CodeBlock from '../../../components/doc/CodeBlock.vue'
type GrLinkTargetMode = 'auto' | '_self' | '_blank' | 'custom'
type GrLinkRelMode = 'auto' | 'noopener noreferrer' | 'nofollow' | 'custom'
const tone = ref<GrLinkTone>('primary')
const variant = ref<GrLinkVariant>('default')
const size = ref<GrLinkSize>('md')
const underline = ref<GrLinkUnderline>('auto')
const label = ref('Open workspace settings')
const href = ref('/settings/workspace')
const ariaLabel = ref('Open workspace settings')
const external = ref(false)
const disabled = ref(false)
const targetMode = ref<GrLinkTargetMode>('auto')
const customTarget = ref('')
const relMode = ref<GrLinkRelMode>('auto')
const customRel = ref('')
const toneOptions = [
{ value: 'primary', label: 'Primary' },
{ value: 'neutral', label: 'Neutral' },
{ value: 'success', label: 'Success' },
{ value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' },
{ value: 'info', label: 'Info' },
{ value: 'slate', label: 'Slate' },
{ value: 'azure', label: 'Azure' },
] satisfies Array<{ value: GrLinkTone, label: string }>
const variantOptions = [
{ value: 'default', label: 'Default' },
{ value: 'muted', label: 'Muted' },
] satisfies Array<{ value: GrLinkVariant, label: string }>
const sizeOptions = [
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrLinkSize, label: string }>
const underlineOptions = [
{ value: 'auto', label: 'Auto' },
{ value: 'always', label: 'Always' },
{ value: 'none', label: 'None' },
] satisfies Array<{ value: GrLinkUnderline, label: string }>
const targetOptions = [
{ value: 'auto', label: 'Auto' },
{ value: '_self', label: '_self' },
{ value: '_blank', label: '_blank' },
{ value: 'custom', label: 'Custom' },
] satisfies Array<{ value: GrLinkTargetMode, label: string }>
const relOptions = [
{ value: 'auto', label: 'Auto' },
{ value: 'noopener noreferrer', label: 'noopener noreferrer' },
{ value: 'nofollow', label: 'nofollow' },
{ value: 'custom', label: 'Custom' },
] satisfies Array<{ value: GrLinkRelMode, label: string }>
const linkText = computed(() => {
return label.value.trim() || 'Open workspace settings'
})
const effectiveAriaLabel = computed(() => {
return ariaLabel.value.trim() || linkText.value
})
const resolvedHref = computed(() => {
return href.value.trim() || '/settings/workspace'
})
const resolvedTarget = computed(() => {
if (targetMode.value === 'custom')
return customTarget.value.trim() || undefined
if (targetMode.value === 'auto')
return undefined
return targetMode.value
})
const resolvedRel = computed(() => {
if (relMode.value === 'custom')
return customRel.value.trim() || undefined
if (relMode.value === 'auto')
return undefined
return relMode.value
})
const previewSummary = computed(() => {
if (disabled.value)
return 'Disabled link рендерится как неинтерактивный inline-элемент и сохраняет типографику текста.'
if (external.value)
return 'External mode автоматически проставляет `target="_blank"` и `rel="noopener noreferrer"`, если вручную их не переопределять.'
if (underline.value === 'always')
return 'Always underline подходит для важных inline-actions, которые должны быть заметны даже без hover.'
return 'Настройте tone, size, underline и навигационные атрибуты, чтобы быстро собрать нужный contract ссылки.'
})
function escapeAttribute(value: string) {
return value.replaceAll('&', '&').replaceAll('"', '"')
}
const previewCode = computed(() => {
const attributes = [
`href="${escapeAttribute(resolvedHref.value)}"`,
`tone="${tone.value}"`,
`variant="${variant.value}"`,
`size="${size.value}"`,
`underline="${underline.value}"`,
]
if (external.value)
attributes.push('external')
if (disabled.value)
attributes.push('disabled')
if (resolvedTarget.value)
attributes.push(`target="${escapeAttribute(resolvedTarget.value)}"`)
if (resolvedRel.value)
attributes.push(`rel="${escapeAttribute(resolvedRel.value)}"`)
if (effectiveAriaLabel.value !== linkText.value)
attributes.push(`aria-label="${escapeAttribute(effectiveAriaLabel.value)}"`)
return ['<GrLink', ...attributes.map(attribute => ` ${attribute}`), '>', ` ${linkText.value}`, '</GrLink>'].join('\n')
})
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
<div class="grid gap-4">
<div
class="relative grid min-h-[280px] rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
>
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
<div class="showcase-demo-caption text-xs">
Preview
</div>
<GrLink
:href="resolvedHref"
:tone="tone"
:variant="variant"
:size="size"
:underline="underline"
:external="external"
:disabled="disabled"
:target="resolvedTarget"
:rel="resolvedRel"
:aria-label="effectiveAriaLabel"
>
{{ linkText }}
</GrLink>
<div class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2">
<div class="showcase-demo-text max-w-[42ch] text-center text-sm">
{{ previewSummary }}
</div>
</div>
</div>
</div>
<CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
</div>
<div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
<div class="showcase-demo-title text-sm font-semibold">
Свойства ссылки
</div>
<div class="grid gap-4">
<GrFormField label="Tone">
<GrSelect v-model="tone" :options="toneOptions" aria-label="Link tone" />
</GrFormField>
<GrFormField label="Variant">
<GrRadioGroup v-model="variant" :options="variantOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Size">
<GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Underline">
<GrRadioGroup v-model="underline" :options="underlineOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Label">
<GrInput
v-model="label"
placeholder="Open workspace settings"
aria-label="Link label"
/>
</GrFormField>
<GrFormField label="Href">
<GrInput
v-model="href"
placeholder="/settings/workspace"
aria-label="Link href"
/>
</GrFormField>
<GrFormField label="Accessibility label">
<GrInput
v-model="ariaLabel"
placeholder="Used by screen readers when needed"
aria-label="Link accessibility label"
/>
</GrFormField>
<GrFormField label="Target">
<GrRadioGroup v-model="targetMode" :options="targetOptions" variant="button" size="sm" />
<GrInput
v-if="targetMode === 'custom'"
v-model="customTarget"
class="mt-3"
placeholder="workspace-frame"
aria-label="Custom target"
/>
</GrFormField>
<GrFormField label="Rel">
<GrSelect v-model="relMode" :options="relOptions" aria-label="Link rel" />
<GrInput
v-if="relMode === 'custom'"
v-model="customRel"
class="mt-3"
placeholder="author noopener"
aria-label="Custom rel"
/>
</GrFormField>
</div>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrSwitch v-model="external" size="sm">
External
</GrSwitch>
<GrSwitch v-model="disabled" size="sm">
Disabled
</GrSwitch>
</div>
</div>
</div>
</template>Варианты и режимы подчёркивания
На витрине важно сравнить tone, underline и size contract, потому что GrLink часто используется как inline action вместо кнопки.
<script setup lang="ts">
import { GrLink, type GrLinkTone } from '@feugene/granularity'
const tones: GrLinkTone[] = ['primary', 'neutral', 'success', 'warning', 'danger', 'info', 'slate', 'azure']
</script>
<template>
<div class="grid gap-5 text-sm">
<div class="grid gap-2">
<div class="text-xs font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
variant="default" — colored by tone
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<GrLink v-for="tone in tones" :key="tone" href="#" :tone="tone" size="md">
{{ tone }}
</GrLink>
</div>
</div>
<div class="grid gap-2">
<div class="text-xs font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
variant="muted" — subdued, tone appears on hover
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<GrLink v-for="tone in tones" :key="tone" href="#" :tone="tone" variant="muted" underline="always" size="md">
{{ tone }}
</GrLink>
</div>
</div>
</div>
</template>Внешние ссылки и смена контекста
Ссылка, открывающаяся в новой вкладке, сама получает иконку, безопасный rel и скрытое предупреждение о смене контекста (WCAG 3.2.5).
<script setup lang="ts">
import { GrLink } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-3 text-sm">
<GrLink href="https://example.com/docs/showcase" external size="md">
Open external documentation
</GrLink>
<!-- Условие — фактическое поведение ссылки, а не проп `external`. -->
<GrLink href="https://example.com/changelog" target="_blank" size="md">
Changelog в новой вкладке
</GrLink>
<GrLink href="https://example.com/rss" external :external-icon="false" size="md">
Без иконки, но с предупреждением для скринридера
</GrLink>
<div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4 text-[var(--gr-muted-fg)]">
Ссылка, открывающаяся в новой вкладке, сама получает иконку, безопасный `rel` и скрытую
подсказку «откроется в новой вкладке» — предупреждение о смене контекста (WCAG 3.2.5).
</div>
</div>
</template>Выключенное и приглушённое состояния
Отдельно показываем disabled/muted сценарии, чтобы было понятно, как GrLink деградирует до неинтерактивного inline элемента.
Disabled mode рендерится как неинтерактивный текстовый элемент и сохраняет тот же layout внутри forms, cards и inline toolbars.
<script setup lang="ts">
import { GrCard, GrLink } from '@feugene/granularity'
</script>
<template>
<GrCard class="grid gap-3 p-4 text-sm">
<div class="flex flex-wrap items-center gap-3">
<GrLink href="#" size="md">
Ready link
</GrLink>
<GrLink href="#" disabled size="md">
Disabled link
</GrLink>
<GrLink href="#" underline="none" variant="muted" size="md">
Muted helper link
</GrLink>
</div>
<p class="text-[var(--gr-muted-fg)]">
Disabled mode рендерится как неинтерактивный текстовый элемент и сохраняет тот же layout внутри forms, cards и inline toolbars.
</p>
</GrCard>
</template>