201 lines
6.1 KiB
Vue
201 lines
6.1 KiB
Vue
<script setup lang="ts">
|
||
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'
|
||
|
||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||
|
||
const props = defineProps<{
|
||
page: number
|
||
pageCount: number
|
||
total: number
|
||
size: number
|
||
loadingPage: number | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:page': [page: number]
|
||
'update:size': [size: number]
|
||
}>()
|
||
|
||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||
|
||
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 items = computed(() => pageItems(props.page, props.pageCount))
|
||
|
||
const summary = computed(() => {
|
||
if (props.loadingPage !== null) {
|
||
return t('Loading page {0}…', [formatNumber(props.loadingPage)])
|
||
}
|
||
const { from, to } = rangeFor(props.page, props.size, props.total)
|
||
return t('Showing {0}–{1} of {2}', [
|
||
formatNumber(from),
|
||
formatNumber(to),
|
||
formatNumber(props.total),
|
||
])
|
||
})
|
||
|
||
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}`
|
||
const ICON_CONTROL = `size-11 md:size-8 ${CONTROL}`
|
||
const CURRENT_PAGE = 'border-primary bg-(--rc-sand) font-semibold text-(--rc-on-sand)'
|
||
|
||
function numberClass(number: number) {
|
||
const sizing = `h-11 min-w-11 px-1.5 md:h-8 md:min-w-8 ${CONTROL}`
|
||
return number === props.page ? `${sizing} ${CURRENT_PAGE}` : `${sizing} font-normal`
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="isDesktop" data-testid="paginator" class="flex items-center gap-4 pt-3">
|
||
<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)"
|
||
/>
|
||
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-1">
|
||
<UButton
|
||
color="neutral"
|
||
variant="outline"
|
||
icon="i-lucide-chevrons-left"
|
||
:disabled="atStart"
|
||
:aria-label="t('First page')"
|
||
:class="ICON_CONTROL"
|
||
@click="emit('update:page', 1)"
|
||
/>
|
||
<UButton
|
||
color="neutral"
|
||
variant="outline"
|
||
icon="i-lucide-chevron-left"
|
||
:disabled="atStart"
|
||
:aria-label="t('Previous page')"
|
||
:class="ICON_CONTROL"
|
||
@click="emit('update:page', page - 1)"
|
||
/>
|
||
<template v-for="(item, index) in items" :key="`${item}-${index}`">
|
||
<span
|
||
v-if="item === 'gap'"
|
||
aria-hidden="true"
|
||
class="flex h-8 min-w-8 items-center justify-center text-muted"
|
||
>…</span
|
||
>
|
||
<UButton
|
||
v-else
|
||
color="neutral"
|
||
variant="outline"
|
||
:disabled="busy"
|
||
:aria-current="item === page ? 'page' : undefined"
|
||
:class="numberClass(item)"
|
||
@click="emit('update:page', item)"
|
||
>
|
||
{{ formatNumber(item) }}
|
||
</UButton>
|
||
</template>
|
||
<UButton
|
||
color="neutral"
|
||
variant="outline"
|
||
icon="i-lucide-chevron-right"
|
||
:disabled="atEnd"
|
||
:aria-label="t('Next page')"
|
||
:class="ICON_CONTROL"
|
||
@click="emit('update:page', page + 1)"
|
||
/>
|
||
<UButton
|
||
color="neutral"
|
||
variant="outline"
|
||
icon="i-lucide-chevrons-right"
|
||
:disabled="atEnd"
|
||
:aria-label="t('Last page')"
|
||
:class="ICON_CONTROL"
|
||
@click="emit('update:page', pageCount)"
|
||
/>
|
||
</nav>
|
||
</div>
|
||
|
||
<div v-else data-testid="paginator" class="flex flex-col gap-3 pt-3 pb-4">
|
||
<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"
|
||
: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)"
|
||
/>
|
||
</div>
|
||
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
|
||
<UButton
|
||
color="neutral"
|
||
variant="outline"
|
||
icon="i-lucide-chevron-left"
|
||
:disabled="atStart"
|
||
:aria-label="t('Previous page')"
|
||
:class="`h-11 flex-1 ${CONTROL}`"
|
||
@click="emit('update:page', page - 1)"
|
||
>
|
||
{{ t('Previous') }}
|
||
</UButton>
|
||
<span
|
||
aria-current="page"
|
||
class="shrink-0 px-1 font-medium whitespace-nowrap text-highlighted"
|
||
>{{ t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)]) }}</span
|
||
>
|
||
<UButton
|
||
color="neutral"
|
||
variant="outline"
|
||
trailing-icon="i-lucide-chevron-right"
|
||
:disabled="atEnd"
|
||
:aria-label="t('Next page')"
|
||
:class="`h-11 flex-1 ${CONTROL}`"
|
||
@click="emit('update:page', page + 1)"
|
||
>
|
||
{{ t('Next') }}
|
||
</UButton>
|
||
</nav>
|
||
</div>
|
||
</template>
|