GrLink

Пакет: @feugene/granularityядроГруппа: Действия

Берут, когда нужен переход.

Когда брать

  • нужен переход — ссылка меняет адрес, и открытие в новой вкладке обязано работать;
  • роутер свой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

PropTypeпо умолчаниюОписание
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"Семантический цвет ссылки из палитры `GrTone`.
variantGrLinkVariant | undefined"default"Уровень акцента: `default` (окрашен) или `muted` (приглушён, акцент на hover).
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
asstring | Component | undefinedundefinedКастомный корневой тег/компонент. Если передан и компонент не `disabled` — рендерится через `<component :is="as">`. Игнорируется при `disabled`.
hrefstring | undefinedundefined
targetstring | undefinedundefined
relstring | undefinedundefined
externalboolean | undefinedfalse
underlineGrLinkUnderline | undefined"auto"
externalIconboolean | undefinedundefinedИконка внешней ссылки. По умолчанию показывается у любой ссылки, которая открывается в новой вкладке, — не только при `external`.
newTabLabelstring | undefinedundefinedi18n: скрытая подсказка «откроется в новой вкладке».

Slots

SlotTypeОписание
defaultanyТекст ссылки.

Примеры 4

Интерактивный конструктор ссылки

Соберите GrLink под ваш сценарий: переключайте tone, underline, size, навигационные атрибуты и сразу смотрите итоговый snippet.

Builderзависит от окружения витрины
<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('&', '&amp;').replaceAll('"', '&quot;')
}

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 вместо кнопки.

variant="default" — colored by tone
variant="muted" — subdued, tone appears on hover

Variants
<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).

Open external documentation (откроется в новой вкладке) Changelog в новой вкладке (откроется в новой вкладке) Без иконки, но с предупреждением для скринридера (откроется в новой вкладке)
Ссылка, открывающаяся в новой вкладке, сама получает иконку, безопасный `rel` и скрытую подсказку «откроется в новой вкладке» — предупреждение о смене контекста (WCAG 3.2.5).

External
<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.

Disabled States
<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>

Документация компонентаВсе компоненты