221 lines
6.7 KiB
Vue
221 lines
6.7 KiB
Vue
<template>
|
|
<div :id="componentId" class="w-full" v-bind="attrs">
|
|
<table :class="twMerge('w-full border-separate border-spacing-0 border border-black rounded-malio overflow-hidden', tableClass)">
|
|
<thead>
|
|
<tr class="bg-m-surface">
|
|
<th
|
|
v-for="col in columns"
|
|
:key="col.key"
|
|
scope="col"
|
|
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]"
|
|
>
|
|
<slot
|
|
v-if="$slots[`header-${col.key}`]"
|
|
:name="`header-${col.key}`"
|
|
:column="col"
|
|
/>
|
|
<span v-else class="font-semibold text-black">{{ col.label }}</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="(item, index) in items"
|
|
:key="index"
|
|
:class="rowClickable ? 'cursor-pointer hover:bg-m-bg' : ''"
|
|
:tabindex="rowClickable ? 0 : undefined"
|
|
data-test="row"
|
|
@click="rowClickable ? emit('row-click', item) : undefined"
|
|
@keydown.enter="rowClickable ? emit('row-click', item) : undefined"
|
|
@keydown.space.prevent="rowClickable ? emit('row-click', item) : undefined"
|
|
>
|
|
<td
|
|
v-for="col in columns"
|
|
:key="col.key"
|
|
class="px-3 py-4 text-[14px] text-black"
|
|
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
|
>
|
|
<slot
|
|
v-if="$slots[`cell-${col.key}`]"
|
|
:name="`cell-${col.key}`"
|
|
:item="item"
|
|
:column="col"
|
|
/>
|
|
<template v-else>{{ item[col.key] }}</template>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!items.length" data-test="empty-row">
|
|
<td
|
|
:colspan="columns.length"
|
|
class="px-3 py-4 text-center text-m-muted"
|
|
>
|
|
<slot name="empty">{{ emptyMessage }}</slot>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div
|
|
v-if="totalItems > 0"
|
|
class="flex items-center justify-between pt-3"
|
|
data-test="pagination"
|
|
>
|
|
<div class="flex items-center gap-4">
|
|
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
|
<div class="h-[30px]">
|
|
<MalioSelect
|
|
:model-value="perPage"
|
|
:options="perPageSelectOptions"
|
|
group-class="w-20 h-[30px]"
|
|
field-class="h-[30px]"
|
|
rounded="rounded"
|
|
text-field="text-sm"
|
|
text-value="text-sm"
|
|
text-label="text-xs"
|
|
data-test="per-page-select"
|
|
@update:model-value="onPerPageChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Préc."
|
|
:disabled="page <= 1"
|
|
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
|
aria-label="Page précédente"
|
|
data-test="prev-button"
|
|
@click="changePage(page - 1)"
|
|
/>
|
|
|
|
<span class="flex items-center gap-2 text-sm">
|
|
<label :for="pageInputId" class="text-m-muted">Page</label>
|
|
<input
|
|
:id="pageInputId"
|
|
v-model="pageInput"
|
|
type="text"
|
|
inputmode="numeric"
|
|
aria-label="Aller à la page"
|
|
data-test="page-input"
|
|
class="h-[30px] w-[58px] rounded-malio border border-m-border text-center text-sm text-m-text outline-none focus:border-m-primary"
|
|
@input="onPageInput"
|
|
@keydown.enter="commitPageInput"
|
|
@blur="commitPageInput"
|
|
>
|
|
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span>
|
|
</span>
|
|
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Suiv."
|
|
:disabled="page >= totalPages"
|
|
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
|
aria-label="Page suivante"
|
|
data-test="next-button"
|
|
@click="changePage(page + 1)"
|
|
/>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, onBeforeUnmount, useAttrs, useId } from 'vue'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import MalioSelect from '../select/Select.vue'
|
|
import MalioButton from '../button/Button.vue'
|
|
|
|
defineOptions({ name: 'MalioDataTable', inheritAttrs: false })
|
|
|
|
type DataTableColumn = {
|
|
key: string
|
|
label: string
|
|
}
|
|
|
|
const attrs = useAttrs()
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
id?: string
|
|
columns: DataTableColumn[]
|
|
items: Record<string, unknown>[]
|
|
totalItems: number
|
|
page?: number
|
|
perPage?: number
|
|
perPageOptions?: number[]
|
|
rowClickable?: boolean
|
|
tableClass?: string
|
|
emptyMessage?: string
|
|
}>(),
|
|
{
|
|
id: '',
|
|
page: 1,
|
|
perPage: 10,
|
|
perPageOptions: () => [10, 25, 50],
|
|
rowClickable: true,
|
|
tableClass: '',
|
|
emptyMessage: 'Aucune donnée',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:page' | 'update:per-page', value: number): void
|
|
(e: 'row-click', item: Record<string, unknown>): void
|
|
}>()
|
|
|
|
const generatedId = useId()
|
|
const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
|
|
|
|
const PAGE_JUMP_DEBOUNCE = 400
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
const pageInputId = computed(() => `${componentId.value}-page-input`)
|
|
const pageInput = ref(String(props.page))
|
|
|
|
watch(() => props.page, (p) => { pageInput.value = String(p) })
|
|
|
|
onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) })
|
|
|
|
const perPageSelectOptions = computed(() =>
|
|
props.perPageOptions.map(n => ({ label: String(n), value: n }))
|
|
)
|
|
|
|
function onPerPageChange(value: string | number | null) {
|
|
if (value !== null) {
|
|
emit('update:per-page', Number(value))
|
|
emit('update:page', 1)
|
|
}
|
|
}
|
|
|
|
function changePage(page: number) {
|
|
if (page >= 1 && page <= totalPages.value && page !== props.page) {
|
|
emit('update:page', page)
|
|
}
|
|
}
|
|
|
|
function onPageInput() {
|
|
pageInput.value = pageInput.value.replace(/[^0-9]/g, '')
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
if (pageInput.value === '') return
|
|
const n = Number(pageInput.value)
|
|
if (n >= 1 && n <= totalPages.value) {
|
|
debounceTimer = setTimeout(() => changePage(n), PAGE_JUMP_DEBOUNCE)
|
|
}
|
|
}
|
|
|
|
function commitPageInput() {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
const raw = pageInput.value.trim()
|
|
const n = Number(raw)
|
|
if (raw === '' || n === 0 || Number.isNaN(n)) {
|
|
pageInput.value = String(props.page)
|
|
return
|
|
}
|
|
const clamped = Math.min(Math.max(1, Math.round(n)), totalPages.value)
|
|
changePage(clamped)
|
|
pageInput.value = String(clamped)
|
|
}
|
|
</script>
|