- 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
114 lines
4.1 KiB
Vue
114 lines
4.1 KiB
Vue
<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>
|