223 lines
6.0 KiB
Vue
223 lines
6.0 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-[20px]"
|
|
>
|
|
<slot
|
|
v-if="$slots[`header-${col.key}`]"
|
|
:name="`header-${col.key}`"
|
|
:column="col"
|
|
/>
|
|
<span v-else class="font-semibold text-m-primary">{{ 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-[18px] text-m-primary"
|
|
: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 justify-between pt-2"
|
|
data-test="pagination"
|
|
>
|
|
<div class="flex gap-4">
|
|
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
|
|
<MalioSelect
|
|
:model-value="perPage"
|
|
:options="perPageSelectOptions"
|
|
min-width="w-20 !mt-0"
|
|
rounded="rounded"
|
|
text-field="text-sm"
|
|
text-value="text-sm"
|
|
text-label="text-xs"
|
|
data-test="per-page-select"
|
|
@update:model-value="onPerPageChange"
|
|
/>
|
|
</div>
|
|
|
|
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Prev"
|
|
:disabled="page <= 1"
|
|
button-class="h-8 w-auto min-w-0 px-3 text-sm"
|
|
aria-label="Page précédente"
|
|
data-test="prev-button"
|
|
@click="goToPage(page - 1)"
|
|
/>
|
|
|
|
<template v-for="(p, idx) in visiblePages" :key="idx">
|
|
<span
|
|
v-if="p === '...'"
|
|
class="px-1 text-sm text-m-muted"
|
|
aria-hidden="true"
|
|
>…</span>
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="h-8 min-w-[2rem] rounded px-2 text-sm transition-colors"
|
|
:class="p === page
|
|
? 'bg-m-btn-primary text-white font-semibold'
|
|
: 'text-m-text hover:bg-m-bg'"
|
|
:aria-current="p === page ? 'page' : undefined"
|
|
:data-test="`page-${p}`"
|
|
@click="goToPage(p)"
|
|
>
|
|
{{ p }}
|
|
</button>
|
|
</template>
|
|
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Next"
|
|
:disabled="page >= totalPages"
|
|
button-class="h-8 w-auto min-w-0 px-3 text-sm"
|
|
aria-label="Page suivante"
|
|
data-test="next-button"
|
|
@click="goToPage(page + 1)"
|
|
/>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, 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 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 goToPage(page: number) {
|
|
if (page >= 1 && page <= totalPages.value) {
|
|
emit('update:page', page)
|
|
}
|
|
}
|
|
|
|
const visiblePages = computed(() => {
|
|
const total = totalPages.value
|
|
const current = props.page
|
|
|
|
if (total <= 5) {
|
|
return Array.from({ length: total }, (_, i) => i + 1)
|
|
}
|
|
|
|
const pages: (number | '...')[] = []
|
|
pages.push(1)
|
|
|
|
if (current > 3) {
|
|
pages.push('...')
|
|
}
|
|
|
|
const start = Math.max(2, current - 1)
|
|
const end = Math.min(total - 1, current + 1)
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
pages.push(i)
|
|
}
|
|
|
|
if (current < total - 2) {
|
|
pages.push('...')
|
|
}
|
|
|
|
if (total > 1) {
|
|
pages.push(total)
|
|
}
|
|
|
|
return pages
|
|
})
|
|
</script>
|