feat: pagination in the SPA for the Sites and Authors lists

This commit is contained in:
Florian Sylvain committed 2026-10-19 04:00:47 +02:00
1 parent 500ad59458
commit 340a0ad461
34 files changed
+2833 -896

No files matched your search

+30 -48
View File
@@ -4,7 +4,7 @@ import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { pageItems, rangeFor } from '@/lib/pagination'
import { ARTICLE_PAGE_SIZES } from '@/lib/articleListQuery'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
const DESKTOP_QUERY = '(min-width: 48rem)'
@@ -14,6 +14,8 @@ const props = defineProps<{
total: number
size: number
loadingPage: number | null
singlePageLabel?: string
flush?: boolean
}>()
const emit = defineEmits<{
@@ -27,6 +29,7 @@ const busy = computed(() => props.loadingPage !== null)
const atStart = computed(() => busy.value || props.page <= 1)
const atEnd = computed(() => busy.value || props.page >= props.pageCount)
const showControls = computed(() => props.pageCount > 1)
const countOnly = computed(() => props.singlePageLabel !== undefined && props.pageCount <= 1)
const items = computed(() => pageItems(props.page, props.pageCount))
const summary = computed(() => {
@@ -41,13 +44,6 @@ const summary = computed(() => {
])
})
const sizeItems = computed(() =>
ARTICLE_PAGE_SIZES.map((value) => ({
label: t('{0} per page', [value]),
value,
})),
)
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const CONTROL = `justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 disabled:opacity-40 aria-disabled:opacity-40 ${FOCUS_RING}`
@@ -61,30 +57,25 @@ function numberClass(number: number) {
</script>
<template>
<div v-if="isDesktop" data-testid="paginator" class="flex items-center gap-4 pt-3">
<p
v-if="countOnly"
data-testid="paginator-count"
class="text-muted max-md:pb-4"
:class="{ 'pt-3': !flush }"
>
{{ singlePageLabel }}
</p>
<div
v-else-if="isDesktop"
data-testid="paginator"
class="flex items-center gap-4"
:class="{ 'pt-3': !flush }"
>
<span aria-live="polite" data-testid="paginator-summary" class="flex-1 text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'start' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `h-8 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: 'h-8 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<PageSizeSelect :size="size" :disabled="busy" @update:size="emit('update:size', $event)" />
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-1">
<UButton
color="neutral"
@@ -144,30 +135,21 @@ function numberClass(number: number) {
</nav>
</div>
<div v-else data-testid="paginator" class="flex flex-col gap-3 pt-3 pb-4">
<div
v-else
data-testid="paginator"
class="flex flex-col gap-3 pb-4"
:class="{ 'pt-3': !flush }"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span aria-live="polite" data-testid="paginator-summary" class="text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
<PageSizeSelect
:size="size"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'end' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `h-11 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: 'h-11 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
}"
@update:model-value="emit('update:size', $event as number)"
touch
@update:size="emit('update:size', $event)"
/>
</div>
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ title: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<UAlert
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="title"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ title: string }>()
defineEmits<{ clear: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-search"
:title="title"
:description="t('Check the spelling, or search for a shorter part of the name.')"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="$emit('clear')"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { DirectoryNotice } from '@/composables/useDirectoryPages'
defineProps<{
notice: DirectoryNotice
shownPage: number
}>()
defineEmits<{
retry: []
}>()
</script>
<template>
<UAlert
v-if="notice.kind === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load page {0}. Showing page {1}.', [notice.query.page, shownPage])"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2 flex-col items-stretch' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 w-full justify-center gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<UAlert
v-else
role="status"
color="info"
variant="soft"
orientation="horizontal"
:close="false"
icon="i-lucide-info"
:description="t('That page doesn\'t exist. Showing the last page.')"
:ui="{
root: 'items-center gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
/>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import AppPaginator from '@/components/AppPaginator.vue'
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
import type { DirectoryNotice } from '@/composables/useDirectoryPages'
defineProps<{
notice: DirectoryNotice | null
showPaginator: boolean
shownPage: number
pageCount: number
total: number
size: number
loadingPage: number | null
singlePageLabel: string
}>()
defineEmits<{
retry: []
'update:page': [page: number]
'update:size': [size: number]
}>()
</script>
<template>
<div v-if="notice?.kind === 'error' || showPaginator" class="flex flex-col gap-3">
<DirectoryPageNotice
v-if="notice?.kind === 'error'"
:notice="notice"
:shown-page="shownPage"
@retry="$emit('retry')"
/>
<AppPaginator
v-if="showPaginator"
:page="shownPage"
:page-count="pageCount"
:total="total"
:size="size"
:loading-page="loadingPage"
:single-page-label="singlePageLabel"
flush
@update:page="$emit('update:page', $event)"
@update:size="$emit('update:size', $event)"
/>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { PAGE_SIZES } from '@/lib/pageQuery'
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const props = defineProps<{
size: number
disabled: boolean
touch?: boolean
}>()
const emit = defineEmits<{ 'update:size': [size: number] }>()
const sizeItems = computed(() =>
PAGE_SIZES.map((value) => ({
label: t('{0} per page', [value]),
value,
})),
)
const height = computed(() => (props.touch ? 'h-11' : 'h-8'))
</script>
<template>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="disabled"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: touch ? 'end' : 'start' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `${height} rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 disabled:opacity-40 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: `${height} rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)`,
}"
@update:model-value="emit('update:size', $event as number)"
/>
</template>