Files
malio-layer-ui/app/components/malio/datatable/DataTable.vue
T

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>