feat: articles pagination
This commit is contained in:
1 parent
a2d224240c
commit
73f4b539fe
45 files changed
+3528
-317
No files matched your search
@@ -0,0 +1,200 @@
|
||||
<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>
|
||||
@@ -6,12 +6,16 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
const props = defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
skeletonRows?: number
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>(),
|
||||
{ skeletonRows: 5 },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
publish: [id: number]
|
||||
@@ -97,7 +101,7 @@ function onRowClick(article: Article, event: MouseEvent) {
|
||||
>
|
||||
<template v-if="loading">
|
||||
<li
|
||||
v-for="n in 5"
|
||||
v-for="n in skeletonRows"
|
||||
:key="n"
|
||||
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
|
||||
>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||
|
||||
const props = defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
skeletonRows?: number
|
||||
sort?: string
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>(),
|
||||
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
publish: [id: number]
|
||||
unpublish: [id: number]
|
||||
delete: [article: Article]
|
||||
'copy-url': [id: number]
|
||||
sort: [field: ArticleSortField]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -26,7 +33,42 @@ const router = useRouter()
|
||||
const columnWidths =
|
||||
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
|
||||
|
||||
const sorting = ref([{ id: 'updated_at', desc: true }])
|
||||
const sorting = computed(() => {
|
||||
const [id, direction] = props.sort.split(':')
|
||||
return [{ id: id!, desc: direction === 'desc' }]
|
||||
})
|
||||
|
||||
const tableRoot = ref<HTMLElement | null>(null)
|
||||
const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
|
||||
|
||||
watchPostEffect(() => {
|
||||
const [active, direction] = props.sort.split(':')
|
||||
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
|
||||
if (SORTABLE_COLUMNS[index] === active) {
|
||||
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
|
||||
} else {
|
||||
header.removeAttribute('aria-sort')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const COLUMN_NAMES = {
|
||||
title: () => t('title'),
|
||||
status: () => t('status'),
|
||||
created_at: () => t('created'),
|
||||
updated_at: () => t('updated'),
|
||||
}
|
||||
|
||||
const caption = computed(() => {
|
||||
if (props.sort === DEFAULT_ARTICLE_SORT) {
|
||||
return t('Articles, sorted by last updated, newest first')
|
||||
}
|
||||
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
|
||||
return t('Articles, sorted by {0}, {1}', [
|
||||
COLUMN_NAMES[field](),
|
||||
direction === 'desc' ? t('descending') : t('ascending'),
|
||||
])
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -34,15 +76,16 @@ const columns = [
|
||||
get header() {
|
||||
return t('Title')
|
||||
},
|
||||
enableSorting: true,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_online',
|
||||
id: 'status',
|
||||
get header() {
|
||||
return t('Status')
|
||||
},
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
@@ -50,7 +93,7 @@ const columns = [
|
||||
return t('Created')
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
@@ -58,20 +101,17 @@ const columns = [
|
||||
return t('Updated')
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableSorting: false,
|
||||
},
|
||||
{ id: 'actions', header: '', size: 44 },
|
||||
]
|
||||
|
||||
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) {
|
||||
const sorted = column.getIsSorted()
|
||||
if (sorted === 'asc') {
|
||||
return 'i-lucide-arrow-up'
|
||||
function sortIcon(field: string) {
|
||||
const [id, direction] = props.sort.split(':')
|
||||
if (id !== field) {
|
||||
return undefined
|
||||
}
|
||||
if (sorted === 'desc') {
|
||||
return 'i-lucide-arrow-down'
|
||||
}
|
||||
return undefined
|
||||
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
||||
}
|
||||
|
||||
function editPath(article: Article) {
|
||||
@@ -143,7 +183,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
|
||||
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
||||
<div
|
||||
v-for="n in 5"
|
||||
v-for="n in skeletonRows"
|
||||
:key="n"
|
||||
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
|
||||
>
|
||||
@@ -153,7 +193,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="hidden border border-default bg-default md:block">
|
||||
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
|
||||
<UTable
|
||||
:data="articles"
|
||||
:columns="columns"
|
||||
@@ -163,13 +203,14 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
th: `px-3 ${columnWidths}`,
|
||||
td: `h-11 px-3 py-0 ${columnWidths}`,
|
||||
}"
|
||||
v-model:sorting="sorting"
|
||||
:sorting="sorting"
|
||||
:sorting-options="{ manualSorting: true }"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #caption>
|
||||
<caption class="sr-only">
|
||||
{{
|
||||
t('Articles, sorted by last updated, newest first')
|
||||
caption
|
||||
}}
|
||||
</caption>
|
||||
</template>
|
||||
@@ -178,21 +219,31 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Title') }}
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #is_online-header="{ column }">
|
||||
<template #status-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Status') }}
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -200,10 +251,15 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Created') }}
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -211,10 +267,15 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Updated') }}
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -229,7 +290,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template #is_online-cell="{ row }">
|
||||
<template #status-cell="{ row }">
|
||||
<ArticleStatusBadge
|
||||
:online="row.original.is_online"
|
||||
size="sm"
|
||||
|
||||
Reference in new issue
Block a user