feat: pagination in the SPA for the Sites and Authors lists
This commit is contained in:
1 parent
500ad59458
commit
340a0ad461
34 files changed
+2833
-896
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user