Files
RenewCMS/web/src/components/AppPaginator.vue
T

201 lines
6.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>