Files
summercms/admin/src/components/list/Pagination.vue
Jakub Zych 8b5f85603f feat(10-03): search, sort, filter, page and bulk-delete any list
- List state (search, sort, dir, page, per_page, filter[<name>]) lives in
  the URL query through parseListQuery/toListQuery; every change is a
  router replace and clears the selection; search is debounced 300 ms and
  resets the page
- DataTable renders the schema columns with a tri-state page checkbox,
  asc/desc/none sorting with aria-sort, selected rows, a sticky header,
  eight skeleton rows while loading and an empty slot
- CellValue renders text (muted dash when empty, arrays comma-joined),
  datetime as YYYY-MM-DD HH:mm and switch as the Tak/Nie pills
- The heading shows the plural recordCount and the create button;
  delete sits in the toolbar, disabled without a selection, and confirms
  with the plural deleteConfirm in a Reka alert dialog before POSTing
  bulk-delete; a 409 shows a danger toast
- FilterBar renders switch (JSON of the option value), daterange
  (from..to) and scope filters (choices from filters/{scope}/options)
- Pagination shows the range, the per-page select over perPageOptions
  (hidden with one choice) and a pager with ellipsis
- The tracer smoke test skips the new checkbox column; new backend::lang
  list keys; boardwalk/dist rebuilt
2026-09-27 16:51:00 +02:00

114 lines
4.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 { computed } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import type { ListMeta } from '../../api/types'
import { t, tc } from '../../app/i18n'
// List footer (design screen 3): the range ":from–:to z :total", the
// per-page select over the schema's options (hidden with one choice,
// research Gap 7) and a pager with ellipsis. Zero rows show the plural
// results message and no pager.
const props = defineProps<{
meta: ListMeta | null
loading: boolean
perPageOptions: number[]
perPage: number
}>()
const emit = defineEmits<{ page: [page: number]; perPage: [perPage: number] }>()
const total = computed(() => props.meta?.total ?? 0)
const page = computed(() => props.meta?.page ?? 1)
const lastPage = computed(() => Math.max(props.meta?.last_page ?? 1, 1))
const range = computed(() => {
const m = props.meta
if (!m || m.total === 0) {
return tc('backend::lang.list.results', 0)
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
/** Page numbers with null for an ellipsis: 1 … p-1 p p+1 … last. */
const pages = computed<Array<number | null>>(() => {
const last = lastPage.value
const current = page.value
const wanted = new Set([1, last, current - 1, current, current + 1].filter((n) => n >= 1 && n <= last))
if (current <= 3) {
;[2, 3].forEach((n) => n <= last && wanted.add(n))
}
if (current >= last - 2) {
;[last - 1, last - 2].forEach((n) => n >= 1 && wanted.add(n))
}
const sorted = [...wanted].sort((a, b) => a - b)
const out: Array<number | null> = []
sorted.forEach((n, index) => {
const prev = sorted[index - 1]
if (prev !== undefined && n - prev > 1) {
out.push(null)
}
out.push(n)
})
return out
})
function onPerPage(event: Event): void {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value) && value > 0) {
emit('perPage', value)
}
}
const pagerButton =
'inline-flex h-pager min-w-pager items-center justify-center gap-1 rounded-pager border border-border px-2.5 text-[13px] text-text disabled:cursor-not-allowed disabled:opacity-60 hover:bg-hover'
</script>
<template>
<footer class="flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-border px-5 py-3.5 text-[13px] text-muted">
<span data-range>{{ loading ? t('backend::lang.list.loading') : range }}</span>
<label v-if="perPageOptions.length > 1" class="flex items-center gap-2">
<span>{{ t('backend::lang.list.records_per_page') }}</span>
<select
data-per-page
:value="String(perPage)"
class="h-pager rounded-pager border border-border-strong bg-surface px-2 text-text"
@change="onPerPage"
>
<option v-for="option in perPageOptions" :key="option" :value="String(option)">{{ option }}</option>
</select>
</label>
<nav
v-if="!loading && total > 0"
:aria-label="t('backend::lang.list.pagination')"
class="ml-auto flex items-center gap-1.5"
>
<button type="button" data-page="prev" :class="pagerButton" :disabled="page <= 1" @click="emit('page', page - 1)">
<ChevronLeft :size="14" aria-hidden="true" />{{ t('backend::lang.list.prev_page') }}
</button>
<template v-for="(n, index) in pages" :key="n ?? `gap-${index}`">
<span v-if="n === null" aria-hidden="true" class="px-1">…</span>
<button
v-else
type="button"
:data-page="n"
:aria-current="n === page ? 'page' : undefined"
:class="[pagerButton, n === page ? 'border-transparent bg-sel font-bold' : '']"
@click="n !== page && emit('page', n)"
>
{{ n }}
</button>
</template>
<button
type="button"
data-page="next"
:class="pagerButton"
:disabled="page >= lastPage"
@click="emit('page', page + 1)"
>
{{ t('backend::lang.list.next_page') }}<ChevronRight :size="14" aria-hidden="true" />
</button>
</nav>
</footer>
</template>