| <template> |
| <div class="md:hidden space-y-3"> |
| <template v-if="loading"> |
| <div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"> |
| <div class="space-y-3"> |
| <div v-for="column in dataColumns" :key="column.key" class="flex justify-between"> |
| <div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> |
| <div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> |
| </div> |
| <div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700"> |
| <div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <template v-else-if="!data || data.length === 0"> |
| <div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900"> |
| <slot name="empty"> |
| <div class="flex flex-col items-center"> |
| <Icon |
| name="inbox" |
| size="xl" |
| class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500" |
| /> |
| <p class="text-lg font-medium text-gray-900 dark:text-gray-100"> |
| {{ t('empty.noData') }} |
| </p> |
| </div> |
| </slot> |
| </div> |
| </template> |
| |
| <template v-else> |
| <div |
| v-for="(row, index) in sortedData" |
| :key="resolveRowKey(row, index)" |
| class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900" |
| > |
| <div class="space-y-3"> |
| <div |
| v-for="column in dataColumns" |
| :key="column.key" |
| class="flex items-start justify-between gap-4" |
| > |
| <span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"> |
| {{ column.label }} |
| </span> |
| <div class="text-right text-sm text-gray-900 dark:text-gray-100"> |
| <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded"> |
| {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }} |
| </slot> |
| </div> |
| </div> |
| <div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700"> |
| <slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot> |
| </div> |
| </div> |
| </div> |
| </template> |
| </div> |
| |
| <div |
| ref="tableWrapperRef" |
| class="table-wrapper hidden md:block" |
| :class="{ |
| 'actions-expanded': actionsExpanded, |
| 'is-scrollable': isScrollable |
| }" |
| > |
| <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> |
| <thead class="table-header bg-gray-50 dark:bg-dark-800"> |
| <tr> |
| <th |
| v-for="(column, index) in columns" |
| :key="column.key" |
| scope="col" |
| :class="[ |
| 'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400', |
| getAdaptivePaddingClass(), |
| { 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }, |
| getStickyColumnClass(column, index), |
| column.class |
| ]" |
| @click="column.sortable && handleSort(column.key)" |
| > |
| <slot |
| :name="`header-${column.key}`" |
| :column="column" |
| :sort-key="sortKey" |
| :sort-order="sortOrder" |
| > |
| <div class="flex items-center space-x-1"> |
| <span>{{ column.label }}</span> |
| <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> |
| <svg |
| v-if="sortKey === column.key" |
| class="h-4 w-4" |
| :class="{ 'rotate-180 transform': sortOrder === 'desc' }" |
| fill="currentColor" |
| viewBox="0 0 20 20" |
| > |
| <path |
| fill-rule="evenodd" |
| d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" |
| clip-rule="evenodd" |
| /> |
| </svg> |
| <svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> |
| <path |
| d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" |
| /> |
| </svg> |
| </span> |
| </div> |
| </slot> |
| </th> |
| </tr> |
| </thead> |
| <tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900"> |
| |
| <tr v-if="loading" v-for="i in 5" :key="i"> |
| <td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]"> |
| <div class="animate-pulse"> |
| <div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div> |
| </div> |
| </td> |
| </tr> |
| |
| |
| <tr v-else-if="!data || data.length === 0"> |
| <td |
| :colspan="columns.length" |
| :class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]" |
| > |
| <slot name="empty"> |
| <div class="flex flex-col items-center"> |
| <Icon |
| name="inbox" |
| size="xl" |
| class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500" |
| /> |
| <p class="text-lg font-medium text-gray-900 dark:text-gray-100"> |
| {{ t('empty.noData') }} |
| </p> |
| </div> |
| </slot> |
| </td> |
| </tr> |
| |
| |
| <template v-else> |
| <tr v-if="virtualPaddingTop > 0" aria-hidden="true"> |
| <td :colspan="columns.length" |
| :style="{ height: virtualPaddingTop + 'px', padding: 0, border: 'none' }"> |
| </td> |
| </tr> |
| <tr |
| v-for="virtualRow in virtualItems" |
| :key="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)" |
| :data-row-id="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)" |
| :data-index="virtualRow.index" |
| :ref="measureElement" |
| class="hover:bg-gray-50 dark:hover:bg-dark-800" |
| > |
| <td |
| v-for="(column, colIndex) in columns" |
| :key="column.key" |
| :class="[ |
| 'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100', |
| getAdaptivePaddingClass(), |
| getStickyColumnClass(column, colIndex), |
| column.class |
| ]" |
| > |
| <slot :name="`cell-${column.key}`" |
| :row="sortedData[virtualRow.index]" |
| :value="sortedData[virtualRow.index][column.key]" |
| :expanded="actionsExpanded"> |
| {{ column.formatter |
| ? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index]) |
| : sortedData[virtualRow.index][column.key] }} |
| </slot> |
| </td> |
| </tr> |
| <tr v-if="virtualPaddingBottom > 0" aria-hidden="true"> |
| <td :colspan="columns.length" |
| :style="{ height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }"> |
| </td> |
| </tr> |
| </template> |
| </tbody> |
| </table> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue' |
| import { useVirtualizer } from '@tanstack/vue-virtual' |
| import { useI18n } from 'vue-i18n' |
| import type { Column } from './types' |
| import Icon from '@/components/icons/Icon.vue' |
| |
| const { t } = useI18n() |
| |
| const emit = defineEmits<{ |
| sort: [key: string, order: 'asc' | 'desc'] |
| }>() |
| |
| |
| const tableWrapperRef = ref<HTMLElement | null>(null) |
| const isScrollable = ref(false) |
| const actionsColumnNeedsExpanding = ref(false) |
| |
| |
| const checkScrollable = () => { |
| if (tableWrapperRef.value) { |
| isScrollable.value = tableWrapperRef.value.scrollWidth > tableWrapperRef.value.clientWidth |
| } |
| } |
| |
| |
| const checkActionsColumnWidth = () => { |
| if (!tableWrapperRef.value) return |
| |
| |
| const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child') |
| if (!firstActionCell) return |
| |
| |
| const actionsContainer = firstActionCell.querySelector('div') |
| if (!actionsContainer) return |
| |
| |
| const wasExpanded = actionsExpanded.value |
| actionsExpanded.value = true |
| |
| |
| nextTick(() => { |
| |
| const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]') |
| if (actionItems.length <= 2) { |
| actionsColumnNeedsExpanding.value = false |
| actionsExpanded.value = wasExpanded |
| return |
| } |
| |
| |
| let totalWidth = 0 |
| actionItems.forEach((item, index) => { |
| totalWidth += (item as HTMLElement).offsetWidth |
| if (index < actionItems.length - 1) { |
| totalWidth += 4 |
| } |
| }) |
| |
| |
| const cellWidth = (firstActionCell as HTMLElement).clientWidth - 32 |
| |
| |
| actionsColumnNeedsExpanding.value = totalWidth > cellWidth |
| |
| |
| actionsExpanded.value = wasExpanded |
| }) |
| } |
| |
| |
| let resizeObserver: ResizeObserver | null = null |
| let resizeHandler: (() => void) | null = null |
| |
| onMounted(() => { |
| checkScrollable() |
| checkActionsColumnWidth() |
| if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') { |
| resizeObserver = new ResizeObserver(() => { |
| checkScrollable() |
| checkActionsColumnWidth() |
| }) |
| resizeObserver.observe(tableWrapperRef.value) |
| } else { |
| |
| resizeHandler = () => { |
| checkScrollable() |
| checkActionsColumnWidth() |
| } |
| window.addEventListener('resize', resizeHandler) |
| } |
| }) |
| |
| onUnmounted(() => { |
| resizeObserver?.disconnect() |
| if (resizeHandler) { |
| window.removeEventListener('resize', resizeHandler) |
| resizeHandler = null |
| } |
| }) |
| |
| interface Props { |
| columns: Column[] |
| data: any[] |
| loading?: boolean |
| stickyFirstColumn?: boolean |
| stickyActionsColumn?: boolean |
| expandableActions?: boolean |
| actionsCount?: number |
| rowKey?: string | ((row: any) => string | number) |
| |
| |
| |
| defaultSortKey?: string |
| defaultSortOrder?: 'asc' | 'desc' |
| |
| |
| |
| |
| sortStorageKey?: string |
| |
| |
| |
| |
| serverSideSort?: boolean |
| |
| estimateRowHeight?: number |
| |
| overscan?: number |
| } |
| |
| const props = withDefaults(defineProps<Props>(), { |
| loading: false, |
| stickyFirstColumn: true, |
| stickyActionsColumn: true, |
| expandableActions: true, |
| defaultSortOrder: 'asc', |
| serverSideSort: false |
| }) |
| |
| const sortKey = ref<string>('') |
| const sortOrder = ref<'asc' | 'desc'>('asc') |
| const actionsExpanded = ref(false) |
| |
| type PersistedSortState = { |
| key: string |
| order: 'asc' | 'desc' |
| } |
| |
| const collator = new Intl.Collator(undefined, { |
| numeric: true, |
| sensitivity: 'base' |
| }) |
| |
| const getSortableKeys = () => { |
| const keys = new Set<string>() |
| for (const col of props.columns) { |
| if (col.sortable) keys.add(col.key) |
| } |
| return keys |
| } |
| |
| const normalizeSortKey = (candidate: string) => { |
| if (!candidate) return '' |
| const sortableKeys = getSortableKeys() |
| return sortableKeys.has(candidate) ? candidate : '' |
| } |
| |
| const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => { |
| return candidate === 'desc' ? 'desc' : 'asc' |
| } |
| |
| const readPersistedSortState = (): PersistedSortState | null => { |
| if (!props.sortStorageKey) return null |
| try { |
| const raw = localStorage.getItem(props.sortStorageKey) |
| if (!raw) return null |
| const parsed = JSON.parse(raw) as Partial<PersistedSortState> |
| const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '') |
| if (!key) return null |
| return { key, order: normalizeSortOrder(parsed.order) } |
| } catch (e) { |
| console.error('[DataTable] Failed to read persisted sort state:', e) |
| return null |
| } |
| } |
| |
| const writePersistedSortState = (state: PersistedSortState) => { |
| if (!props.sortStorageKey) return |
| try { |
| localStorage.setItem(props.sortStorageKey, JSON.stringify(state)) |
| } catch (e) { |
| console.error('[DataTable] Failed to persist sort state:', e) |
| } |
| } |
| |
| const resolveInitialSortState = (): PersistedSortState | null => { |
| const persisted = readPersistedSortState() |
| if (persisted) return persisted |
| |
| const key = normalizeSortKey(props.defaultSortKey || '') |
| if (!key) return null |
| return { key, order: normalizeSortOrder(props.defaultSortOrder) } |
| } |
| |
| const applySortState = (state: PersistedSortState | null) => { |
| if (!state) return |
| sortKey.value = state.key |
| sortOrder.value = state.order |
| } |
| |
| const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === '' |
| |
| const toFiniteNumberOrNull = (value: any): number | null => { |
| if (typeof value === 'number') return Number.isFinite(value) ? value : null |
| if (typeof value === 'boolean') return value ? 1 : 0 |
| if (typeof value === 'string') { |
| const trimmed = value.trim() |
| if (!trimmed) return null |
| const n = Number(trimmed) |
| return Number.isFinite(n) ? n : null |
| } |
| return null |
| } |
| |
| const toSortableString = (value: any): string => { |
| if (value === null || value === undefined) return '' |
| if (typeof value === 'string') return value |
| if (typeof value === 'number' || typeof value === 'boolean') return String(value) |
| if (value instanceof Date) return value.toISOString() |
| try { |
| return JSON.stringify(value) |
| } catch { |
| return String(value) |
| } |
| } |
| |
| const compareSortValues = (a: any, b: any): number => { |
| const aEmpty = isNullishOrEmpty(a) |
| const bEmpty = isNullishOrEmpty(b) |
| if (aEmpty && bEmpty) return 0 |
| if (aEmpty) return 1 |
| if (bEmpty) return -1 |
| |
| const aNum = toFiniteNumberOrNull(a) |
| const bNum = toFiniteNumberOrNull(b) |
| if (aNum !== null && bNum !== null) { |
| if (aNum === bNum) return 0 |
| return aNum < bNum ? -1 : 1 |
| } |
| |
| const aStr = toSortableString(a) |
| const bStr = toSortableString(b) |
| const res = collator.compare(aStr, bStr) |
| if (res === 0) return 0 |
| return res < 0 ? -1 : 1 |
| } |
| const resolveRowKey = (row: any, index: number) => { |
| if (typeof props.rowKey === 'function') { |
| const key = props.rowKey(row) |
| return key ?? index |
| } |
| if (typeof props.rowKey === 'string' && props.rowKey) { |
| const key = row?.[props.rowKey] |
| return key ?? index |
| } |
| const key = row?.id |
| return key ?? index |
| } |
| |
| const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions')) |
| const columnsSignature = computed(() => |
| props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|') |
| ) |
| |
| |
| |
| watch( |
| [() => props.data.length, columnsSignature], |
| async () => { |
| await nextTick() |
| checkScrollable() |
| checkActionsColumnWidth() |
| }, |
| { flush: 'post' } |
| ) |
| |
| |
| watch(actionsExpanded, async () => { |
| await nextTick() |
| checkScrollable() |
| }) |
| |
| const handleSort = (key: string) => { |
| let newOrder: 'asc' | 'desc' = 'asc' |
| if (sortKey.value === key) { |
| newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc' |
| } |
| |
| if (props.serverSideSort) { |
| |
| sortKey.value = key |
| sortOrder.value = newOrder |
| emit('sort', key, newOrder) |
| } else { |
| |
| sortKey.value = key |
| sortOrder.value = newOrder |
| } |
| } |
| |
| const sortedData = computed(() => { |
| |
| if (props.serverSideSort || !sortKey.value || !props.data) return props.data |
| |
| const key = sortKey.value |
| const order = sortOrder.value |
| |
| |
| return props.data |
| .map((row, index) => ({ row, index })) |
| .sort((a, b) => { |
| const cmp = compareSortValues(a.row?.[key], b.row?.[key]) |
| if (cmp !== 0) return order === 'asc' ? cmp : -cmp |
| return a.index - b.index |
| }) |
| .map(item => item.row) |
| }) |
| |
| |
| const rowVirtualizer = useVirtualizer(computed(() => ({ |
| count: sortedData.value?.length ?? 0, |
| getScrollElement: () => tableWrapperRef.value, |
| estimateSize: () => props.estimateRowHeight ?? 56, |
| overscan: props.overscan ?? 5, |
| }))) |
| |
| const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems()) |
| |
| const virtualPaddingTop = computed(() => { |
| const items = virtualItems.value |
| return items.length > 0 ? items[0].start : 0 |
| }) |
| |
| const virtualPaddingBottom = computed(() => { |
| const items = virtualItems.value |
| if (items.length === 0) return 0 |
| return rowVirtualizer.value.getTotalSize() - items[items.length - 1].end |
| }) |
| |
| const measureElement = (el: any) => { |
| if (el) { |
| rowVirtualizer.value.measureElement(el as Element) |
| } |
| } |
| |
| const hasActionsColumn = computed(() => { |
| return props.columns.some(column => column.key === 'actions') |
| }) |
| |
| const hasSelectColumn = computed(() => { |
| return props.columns.length > 0 && props.columns[0].key === 'select' |
| }) |
| |
| |
| const getStickyColumnClass = (column: Column, index: number) => { |
| const classes: string[] = [] |
| |
| if (props.stickyFirstColumn) { |
| |
| if (hasSelectColumn.value) { |
| if (index === 0) { |
| classes.push('sticky-col sticky-col-left-first') |
| } else if (index === 1) { |
| classes.push('sticky-col sticky-col-left-second') |
| } |
| } else { |
| |
| if (index === 0) { |
| classes.push('sticky-col sticky-col-left') |
| } |
| } |
| } |
| |
| |
| if (props.stickyActionsColumn && column.key === 'actions') { |
| classes.push('sticky-col sticky-col-right') |
| } |
| |
| return classes.join(' ') |
| } |
| |
| |
| const getAdaptivePaddingClass = () => { |
| const columnCount = props.columns.length |
| |
| |
| if (columnCount >= 10) { |
| return 'px-2' |
| } else if (columnCount >= 7) { |
| return 'px-3' |
| } else if (columnCount >= 5) { |
| return 'px-4' |
| } else { |
| return 'px-6' |
| } |
| } |
| |
| |
| const didInitSort = ref(false) |
| |
| onMounted(() => { |
| const initial = resolveInitialSortState() |
| applySortState(initial) |
| didInitSort.value = true |
| }) |
| |
| watch( |
| columnsSignature, |
| () => { |
| |
| const normalized = normalizeSortKey(sortKey.value) |
| if (!sortKey.value) { |
| const initial = resolveInitialSortState() |
| applySortState(initial) |
| return |
| } |
| |
| if (!normalized) { |
| const fallback = resolveInitialSortState() |
| if (fallback) { |
| applySortState(fallback) |
| } else { |
| sortKey.value = '' |
| sortOrder.value = 'asc' |
| } |
| } |
| }, |
| { flush: 'post' } |
| ) |
| |
| watch( |
| [sortKey, sortOrder], |
| ([nextKey, nextOrder]) => { |
| if (!didInitSort.value) return |
| if (!props.sortStorageKey) return |
| const key = normalizeSortKey(nextKey) |
| if (!key) return |
| writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) }) |
| }, |
| { flush: 'post' } |
| ) |
| |
| defineExpose({ |
| virtualizer: rowVirtualizer, |
| sortedData, |
| resolveRowKey, |
| tableWrapperEl: tableWrapperRef, |
| }) |
| </script> |
| |
| <style scoped> |
| |
| .table-wrapper { |
| --select-col-width: 52px; |
| position: relative; |
| overflow-x: auto; |
| overflow-y: auto; |
| flex: 1; |
| min-height: 0; |
| isolation: isolate; |
| } |
| |
| |
| .table-wrapper .table-header { |
| position: sticky; |
| top: 0; |
| z-index: 200; |
| background-color: rgb(249 250 251); |
| } |
| |
| .dark .table-wrapper .table-header { |
| background-color: rgb(31 41 55); |
| } |
| |
| |
| .table-body { |
| position: relative; |
| z-index: 0; |
| } |
| |
| |
| .sticky-header-cell { |
| position: sticky; |
| top: 0; |
| z-index: 210; |
| background-color: rgb(249 250 251); |
| } |
| |
| .dark .sticky-header-cell { |
| background-color: rgb(31 41 55); |
| } |
| |
| |
| .sticky-col { |
| position: sticky; |
| z-index: 20; |
| } |
| |
| |
| .sticky-col-left { |
| left: 0; |
| } |
| |
| |
| .sticky-col-left-first { |
| left: 0; |
| } |
| |
| |
| .sticky-col-left-second { |
| left: var(--select-col-width); |
| } |
| |
| |
| .sticky-col-right { |
| right: 0; |
| } |
| |
| |
| .sticky-header-cell.sticky-col { |
| z-index: 220; |
| } |
| |
| |
| tbody .sticky-col { |
| background-color: white; |
| } |
| |
| .dark tbody .sticky-col { |
| background-color: rgb(17 24 39); |
| } |
| |
| |
| tbody tr:hover .sticky-col { |
| background-color: rgb(249 250 251); |
| } |
| |
| .dark tbody tr:hover .sticky-col { |
| background-color: rgb(31 41 55); |
| } |
| |
| |
| |
| .is-scrollable .sticky-col-left::after { |
| content: ''; |
| position: absolute; |
| top: 0; |
| right: 0; |
| bottom: 0; |
| width: 10px; |
| transform: translateX(100%); |
| background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent); |
| pointer-events: none; |
| } |
| |
| |
| .is-scrollable .sticky-col-left-second::after { |
| content: ''; |
| position: absolute; |
| top: 0; |
| right: 0; |
| bottom: 0; |
| width: 10px; |
| transform: translateX(100%); |
| background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent); |
| pointer-events: none; |
| } |
| |
| |
| .is-scrollable .sticky-col-right::before { |
| content: ''; |
| position: absolute; |
| top: 0; |
| left: 0; |
| bottom: 0; |
| width: 10px; |
| transform: translateX(-100%); |
| background: linear-gradient(to left, rgba(0, 0, 0, 0.08), transparent); |
| pointer-events: none; |
| } |
| |
| |
| .dark .is-scrollable .sticky-col-left::after, |
| .dark .is-scrollable .sticky-col-left-second::after { |
| background: linear-gradient(to right, rgba(0, 0, 0, 0.2), transparent); |
| } |
| |
| .dark .is-scrollable .sticky-col-right::before { |
| background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent); |
| } |
| </style> |
| |