101 lines
4.0 KiB
Vue
101 lines
4.0 KiB
Vue
<template>
|
|
<article class="glass-panel resource-table-panel">
|
|
<div class="resource-toolbar">
|
|
<slot name="toolbar-leading" />
|
|
<label class="search-control">
|
|
<Search :size="15" />
|
|
<input :value="search" :placeholder="searchPlaceholder" @input="$emit('update:search', $event.target.value)" />
|
|
<button v-if="search" class="search-clear" type="button" aria-label="Clear search" @click="$emit('update:search', '')">
|
|
<X :size="13" />
|
|
</button>
|
|
</label>
|
|
<label class="table-density-select">
|
|
<span>Rows</span>
|
|
<select :value="pageSize" @change="$emit('update:pageSize', Number($event.target.value))">
|
|
<option :value="8">8</option>
|
|
<option :value="12">12</option>
|
|
<option :value="20">20</option>
|
|
</select>
|
|
</label>
|
|
<span class="toolbar-count">{{ total }} {{ itemLabel }}</span>
|
|
<slot name="toolbar-actions" />
|
|
</div>
|
|
<div v-if="search || sort" class="resource-filter-strip">
|
|
<span v-if="search">Search: <strong>{{ search }}</strong></span>
|
|
<span v-if="sort">Sort: <strong>{{ activeSortLabel }}</strong> {{ direction }}</span>
|
|
</div>
|
|
<div v-if="rows.length" class="data-table-wrap">
|
|
<table class="data-table resource-table">
|
|
<thead>
|
|
<tr>
|
|
<th v-for="column in columns" :key="column.key">
|
|
<button v-if="column.sortable !== false" type="button" :aria-sort="sortAria(column.key)" @click="$emit('sort', column.key)">
|
|
{{ column.label }}
|
|
<component :is="sortIcon(column.key)" :size="13" />
|
|
</button>
|
|
<template v-else>{{ column.label }}</template>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in rows" :key="row.id">
|
|
<td v-for="column in columns" :key="column.key" :class="column.class">
|
|
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
|
{{ row[column.key] || '-' }}
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-else class="resource-empty-shell">
|
|
<slot name="empty">
|
|
<div class="widget-empty">{{ emptyText }}</div>
|
|
</slot>
|
|
</div>
|
|
<div class="pagination">
|
|
<button class="ghost-button compact" :disabled="page <= 1" aria-label="Previous page" @click="$emit('update:page', page - 1)">
|
|
<ChevronLeft :size="15" />Previous
|
|
</button>
|
|
<span>Page {{ Math.min(page, pageCount) }} of {{ pageCount }}</span>
|
|
<button class="ghost-button compact" :disabled="page >= pageCount" aria-label="Next page" @click="$emit('update:page', page + 1)">
|
|
Next<ChevronRight :size="15" />
|
|
</button>
|
|
</div>
|
|
</article>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue';
|
|
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue';
|
|
|
|
const props = defineProps({
|
|
columns: { type: Array, required: true },
|
|
rows: { type: Array, required: true },
|
|
total: { type: Number, required: true },
|
|
search: { type: String, default: '' },
|
|
page: { type: Number, default: 1 },
|
|
pageCount: { type: Number, default: 1 },
|
|
pageSize: { type: Number, default: 8 },
|
|
sort: { type: String, default: '' },
|
|
direction: { type: String, default: 'asc' },
|
|
searchPlaceholder: { type: String, default: 'Search...' },
|
|
itemLabel: { type: String, default: 'items' },
|
|
emptyText: { type: String, default: 'No rows match the current filters.' }
|
|
});
|
|
|
|
defineEmits(['update:search', 'update:page', 'update:pageSize', 'sort']);
|
|
|
|
const activeSortLabel = computed(() => props.columns.find((column) => column.key === props.sort)?.label || props.sort);
|
|
|
|
function sortIcon(key) {
|
|
if (props.sort !== key) return ChevronsUpDown;
|
|
return props.direction === 'asc' ? ArrowUpAZ : ArrowDownAZ;
|
|
}
|
|
|
|
function sortAria(key) {
|
|
if (props.sort !== key) return 'none';
|
|
return props.direction === 'asc' ? 'ascending' : 'descending';
|
|
}
|
|
</script>
|