Files
vue-seo-layer/app/components/Seo/Pagination.vue
Jakub Zych f2d13d6264 feat: pagination component and composable
- <SeoPagination> (UI-SPEC L2): previous and next (left out at the
  ends), page 1, the last page and five pages around the current one with
  gap items; nothing when last <= 1; props current, last, labels,
  classPrefix, locale
- usePagination(): hrefs in the CMS form (resolve answer, then site
  answer, then seo.pagination), other query parameters kept, page 1
  unnumbered, <link rel=prev/next> in the head
- the CMS head fills %pages% in the title and description once the
  listing's page count is known (seoFillPages)
- seoPaginationWindow, seoPageHref, seoPageNumber; L2 default styles
2026-11-04 11:59:51 +01:00

57 lines
2.8 KiB
Vue

<script setup lang="ts">
// Numbered, server-rendered page links (UI-SPEC L2, D-40, D-42): previous
// and next (left out at the ends), page 1, the last page and five pages
// around the current one with gap items, in the CMS-chosen URL form
// (usePagination, which also adds <link rel="prev/next"> to the head and
// fills %pages% in the CMS title). Renders nothing when last ≤ 1.
// classPrefix renames every class, so a layer keeps its own markup classes
// (vue-journal-layer renders it with class-prefix="journal-pagination").
// Links render through NuxtLink's custom slot: client navigation without
// the router's active classes and aria-current, which would otherwise mark
// every page of the same path (the router ignores the query).
import { formatSeoLabel, type SeoLabels } from '../../utils/labels.ts'
import { SEO_PAGINATION_GAP } from '../../utils/paginationWindow.ts'
const props = withDefaults(
defineProps<{
current: number
last: number
/** Overrides the pagination.* labels; page takes {page}. */
labels?: Partial<SeoLabels['pagination']>
classPrefix?: string
/** The listing's locale (labels and the path segment; default: the page's content locale). */
locale?: string
}>(),
{ labels: () => ({}), classPrefix: 'seo-pagination', locale: '' },
)
const dictionary = useSeoLabels(() => props.locale)
const l = computed(() => ({ ...dictionary.value.pagination, ...Object.fromEntries(Object.entries(props.labels ?? {}).filter(([, v]) => typeof v === 'string' && v !== '')) }))
const { current, last, items, href } = usePagination({ current: () => props.current, last: () => props.last, locale: () => props.locale })
const c = (part: string) => `${props.classPrefix}__${part}`
</script>
<template>
<nav v-if="last > 1" :class="classPrefix" :aria-label="l.label">
<NuxtLink v-if="current > 1" v-slot="link" :to="href(current - 1)" custom>
<a :class="c('prev')" :href="link.href" rel="prev" @click="link.navigate">{{ l.previous }}</a>
</NuxtLink>
<ol :class="c('pages')">
<template v-for="(item, i) in items" :key="item === SEO_PAGINATION_GAP ? `gap-${i}` : item">
<li v-if="item === SEO_PAGINATION_GAP" :class="c('gap')" aria-hidden="true">…</li>
<li v-else-if="item === current">
<span :class="c('current')" aria-current="page">{{ item }}</span>
</li>
<li v-else>
<NuxtLink v-slot="link" :to="href(item)" custom>
<a :href="link.href" :aria-label="formatSeoLabel(l.page, { page: item })" @click="link.navigate">{{ item }}</a>
</NuxtLink>
</li>
</template>
</ol>
<NuxtLink v-if="current < last" v-slot="link" :to="href(current + 1)" custom>
<a :class="c('next')" :href="link.href" rel="next" @click="link.navigate">{{ l.next }}</a>
</NuxtLink>
</nav>
</template>