@kikiloaw/simple-table 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,795 @@
1
+ <script setup lang="ts" generic="T">
2
+ import { computed, ref, watch, onMounted } from 'vue'
3
+ import { router } from '@inertiajs/vue3'
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from './components/table'
12
+
13
+
14
+
15
+ import { useDebounceFn } from '@vueuse/core'
16
+
17
+ /**
18
+ * Props definition
19
+ */
20
+ interface Props {
21
+ data?: any[] | Record<string, any> // Array for client-side, Object (Paginator) for server-side
22
+ columns: {
23
+ key: string
24
+ label: string
25
+ sortable?: boolean | string // true for default (use key), string for custom backend column name
26
+ class?: string
27
+ fixed?: boolean // 'left' or 'right' could be added later, assuming 'right' for actions usually
28
+ width?: string
29
+ }[]
30
+ mode?: 'auto' | 'server' | 'client'
31
+ protocol?: 'laravel' | 'datatables' // API request/response format
32
+ searchable?: boolean
33
+ perPage?: number
34
+ pageSizes?: any[] // number[] or { label: string, value: number }[]
35
+ fetchUrl?: string
36
+
37
+ // Cache Props
38
+ enableCache?: boolean // If true, cache responses by page/search/sort to avoid redundant requests
39
+
40
+ // Additional Query Parameters
41
+ queryParams?: Record<string, any> // Additional parameters to send with every request (e.g., filters, user context)
42
+
43
+ // Style Props
44
+ oddRowColor?: string // Tailwind color class, e.g. 'bg-white'
45
+ evenRowColor?: string // Tailwind color class, e.g. 'bg-gray-50'
46
+ hoverColor?: string // Tailwind color class for hover, e.g. 'hover:bg-gray-100'. If passed, we'll try to apply group-hover for fixed cols.
47
+ }
48
+
49
+ const props = withDefaults(defineProps<Props>(), {
50
+ data: () => [],
51
+ mode: 'auto',
52
+ protocol: 'laravel',
53
+ searchable: true,
54
+ enableCache: false,
55
+ queryParams: () => ({}),
56
+ perPage: 10,
57
+ pageSizes: () => [10, 20, 30, 50, 100],
58
+ oddRowColor: 'bg-background',
59
+ evenRowColor: 'bg-background',
60
+ hoverColor: 'hover:bg-muted/50'
61
+ })
62
+
63
+ // ...
64
+
65
+ // -- Computed: Page Sizes Normalization --
66
+ const normalizedPageSizes = computed(() => {
67
+ if (!props.pageSizes || props.pageSizes.length === 0) return []
68
+
69
+ // Check first item type
70
+ const first = props.pageSizes[0]
71
+
72
+ // If simple numbers [10, 20]
73
+ if (typeof first === 'number' || typeof first === 'string') {
74
+ return props.pageSizes.map(v => ({ label: String(v), value: String(v) }))
75
+ }
76
+
77
+ // If objects [{ label: 'All', value: 999 }, { label: '20', value: 20 }]
78
+ if (typeof first === 'object' && 'label' in first && 'value' in first) {
79
+ return props.pageSizes.map(v => ({ label: v.label, value: String(v.value) }))
80
+ }
81
+
82
+ // Fallback?
83
+ return []
84
+ })
85
+
86
+
87
+
88
+ // <template>
89
+ // ...
90
+ /*
91
+ <div class="flex items-center gap-2 ml-auto">
92
+ <!-- Mini Export Buttons -->
93
+ <Button
94
+ v-if="exportable || enableCsv"
95
+ variant="outline"
96
+ size="icon"
97
+ class="h-8 w-8"
98
+ title="Export CSV"
99
+ @click="handleExport('csv')"
100
+ >
101
+ <FileText class="h-4 w-4 text-green-600" />
102
+ </Button>
103
+ <Button
104
+ v-if="exportable || enableExcel"
105
+ variant="outline"
106
+ size="icon"
107
+ class="h-8 w-8"
108
+ title="Export Excel"
109
+ @click="handleExport('excel')"
110
+ >
111
+ <Sheet class="h-4 w-4 text-emerald-600" />
112
+ </Button>
113
+ <Button
114
+ v-if="exportable || enablePdf"
115
+ variant="outline"
116
+ size="icon"
117
+ class="h-8 w-8"
118
+ title="Export PDF"
119
+ @click="handleExport('pdf')"
120
+ >
121
+ <!-- Using Download icon for PDF or maybe FileText? Let's use Download for generic or find a PDF-like icon. FileText is close. -->
122
+ <FileText class="h-4 w-4 text-red-600" />
123
+ </Button>
124
+ <slot name="actions" />
125
+ </div>
126
+ */
127
+
128
+ const emit = defineEmits(['update:search', 'update:sort', 'page-change'])
129
+
130
+ // -- State --
131
+ const searchQuery = ref('')
132
+ const sortColumn = ref('')
133
+ const sortDirection = ref<'asc' | 'desc'>('asc')
134
+ const currentPage = ref(1)
135
+ const isLoading = ref(false)
136
+
137
+ const currentPerPage = ref(props.perPage)
138
+ const drawCounter = ref(1) // For DataTables protocol
139
+
140
+
141
+ // -- Cache System --
142
+ const responseCache = ref<Map<string, any>>(new Map())
143
+
144
+ function getCacheKey(): string {
145
+ // Create a unique key based on current state
146
+ return JSON.stringify({
147
+ page: currentPage.value,
148
+ perPage: currentPerPage.value,
149
+ search: searchQuery.value,
150
+ sort: sortColumn.value,
151
+ order: sortDirection.value,
152
+ queryParams: props.queryParams
153
+ })
154
+ }
155
+
156
+ function clearCache() {
157
+ responseCache.value.clear()
158
+ }
159
+
160
+ // Internal data state to handle both props updates and ajax updates
161
+ const internalData = ref(props.data)
162
+
163
+ watch(() => props.data, (newVal) => {
164
+ internalData.value = newVal
165
+ }, { deep: true })
166
+
167
+ // Watch queryParams and refetch when they change
168
+ watch(() => props.queryParams, () => {
169
+ if (isServerSide.value) {
170
+ currentPage.value = 1 // Reset to first page when filters change
171
+ fetchData()
172
+ }
173
+ }, { deep: true })
174
+
175
+ // -- Computed: Mode Detection --
176
+ const isServerSide = computed(() => {
177
+ if (props.mode === 'server') return true
178
+ if (props.mode === 'client') return false
179
+ if (props.fetchUrl) return true
180
+
181
+ // Auto detect
182
+ const d = internalData.value as any
183
+ if (d && typeof d === 'object' && !Array.isArray(d)) {
184
+ if ('current_page' in d) return true
185
+ if (d.meta && 'current_page' in d.meta) return true
186
+ }
187
+ return false
188
+ })
189
+
190
+ // -- Helper Data Accessors --
191
+ const serverMeta = computed(() => {
192
+ if (!isServerSide.value) return null
193
+ const d = internalData.value as any
194
+ // Handle standard Laravel Paginator or Resource Collection
195
+ const meta = d.meta || d
196
+ return {
197
+ current_page: meta.current_page ?? 1,
198
+ last_page: meta.last_page ?? 1,
199
+ from: meta.from ?? 0,
200
+ to: meta.to ?? 0,
201
+ total: meta.total ?? 0,
202
+ links: meta.links ?? []
203
+ }
204
+ })
205
+
206
+ // -- Computed: Data Normalization --
207
+ const tableData = computed(() => {
208
+ if (isServerSide.value) {
209
+ const d = internalData.value as any
210
+ return d.data || []
211
+ }
212
+
213
+ // Client Side Processing
214
+ let items = [...(internalData.value as any[])]
215
+
216
+ // 1. Filter
217
+ if (searchQuery.value) {
218
+ const lowerQuery = searchQuery.value.toLowerCase()
219
+ items = items.filter((item) =>
220
+ Object.values(item).some((val) =>
221
+ String(val).toLowerCase().includes(lowerQuery)
222
+ )
223
+ )
224
+ }
225
+
226
+ // 2. Sort
227
+ if (sortColumn.value) {
228
+ items.sort((a, b) => {
229
+ const valA = a[sortColumn.value]
230
+ const valB = b[sortColumn.value]
231
+ if (valA === valB) return 0
232
+ const comparison = valA > valB ? 1 : -1
233
+ return sortDirection.value === 'asc' ? comparison : -comparison
234
+ })
235
+ }
236
+
237
+ // 3. Paginate
238
+ const start = (currentPage.value - 1) * currentPerPage.value
239
+ const end = start + currentPerPage.value
240
+ return items.slice(start, end)
241
+ })
242
+
243
+ const totalPages = computed(() => {
244
+ if (isServerSide.value) {
245
+ return serverMeta.value?.last_page || 1
246
+ }
247
+ let filtered = (internalData.value as any[])
248
+ if (searchQuery.value) {
249
+ const lowerQuery = searchQuery.value.toLowerCase()
250
+ filtered = filtered.filter((item) =>
251
+ Object.values(item).some((val) =>
252
+ String(val).toLowerCase().includes(lowerQuery)
253
+ )
254
+ )
255
+ }
256
+ return Math.ceil(filtered.length / currentPerPage.value) || 1
257
+ })
258
+
259
+ const paginationMeta = computed(() => {
260
+ if (isServerSide.value) {
261
+ return serverMeta.value || { from: 0, to: 0, total: 0 }
262
+ }
263
+ // Client side meta
264
+ let filtered = (internalData.value as any[])
265
+ if (searchQuery.value) {
266
+ const lowerQuery = searchQuery.value.toLowerCase()
267
+ filtered = filtered.filter((item) =>
268
+ Object.values(item).some((val) =>
269
+ String(val).toLowerCase().includes(lowerQuery)
270
+ )
271
+ )
272
+ }
273
+ const total = filtered.length;
274
+ const from = total === 0 ? 0 : (currentPage.value - 1) * currentPerPage.value + 1
275
+ const to = Math.min(from + currentPerPage.value - 1, total)
276
+
277
+ return { from, to, total }
278
+ })
279
+
280
+ // -- Computed: Page Numbers for Pagination --
281
+ const pageNumbers = computed(() => {
282
+ const current = isServerSide.value ? (serverMeta.value?.current_page || 1) : currentPage.value
283
+ const total = totalPages.value
284
+ const delta = 2 // Number of pages to show on each side of current page
285
+ const pages: (number | string)[] = []
286
+
287
+ // Always show first page
288
+ pages.push(1)
289
+
290
+ // Calculate range around current page
291
+ const rangeStart = Math.max(2, current - delta)
292
+ const rangeEnd = Math.min(total - 1, current + delta)
293
+
294
+ // Add ellipsis after first page if needed
295
+ if (rangeStart > 2) {
296
+ pages.push('...')
297
+ }
298
+
299
+ // Add pages in range
300
+ for (let i = rangeStart; i <= rangeEnd; i++) {
301
+ pages.push(i)
302
+ }
303
+
304
+ // Add ellipsis before last page if needed
305
+ if (rangeEnd < total - 1) {
306
+ pages.push('...')
307
+ }
308
+
309
+ // Always show last page if there's more than one page
310
+ if (total > 1) {
311
+ pages.push(total)
312
+ }
313
+
314
+ return pages
315
+ })
316
+
317
+ // -- Methods --
318
+
319
+ async function fetchData(params: any = {}) {
320
+ if (props.fetchUrl) {
321
+ // Check cache first if enabled
322
+ const cacheKey = getCacheKey()
323
+ if (props.enableCache && responseCache.value.has(cacheKey)) {
324
+ // Use cached data
325
+ internalData.value = responseCache.value.get(cacheKey)
326
+ return
327
+ }
328
+
329
+ isLoading.value = true
330
+ try {
331
+ // Construct Query Parameters
332
+ const url = new URL(props.fetchUrl, window.location.origin)
333
+
334
+ if (props.protocol === 'datatables') {
335
+ // DataTables format
336
+ const start = (currentPage.value - 1) * currentPerPage.value
337
+ url.searchParams.append('start', String(start))
338
+ url.searchParams.append('length', String(currentPerPage.value))
339
+ url.searchParams.append('draw', String(drawCounter.value))
340
+
341
+ if (searchQuery.value) {
342
+ url.searchParams.append('search[value]', searchQuery.value)
343
+ }
344
+
345
+ if (sortColumn.value) {
346
+ // Find column index
347
+ const columnIndex = props.columns.findIndex(col => {
348
+ const sortKey = typeof col.sortable === 'string' ? col.sortable : col.key
349
+ return sortKey === sortColumn.value
350
+ })
351
+
352
+ if (columnIndex !== -1) {
353
+ url.searchParams.append('order[0][column]', String(columnIndex))
354
+ url.searchParams.append('order[0][dir]', sortDirection.value)
355
+ }
356
+ }
357
+ } else {
358
+ // Laravel format (default)
359
+ url.searchParams.append('page', String(currentPage.value))
360
+ url.searchParams.append('per_page', String(currentPerPage.value))
361
+
362
+ if (searchQuery.value) {
363
+ url.searchParams.append('search', searchQuery.value)
364
+ }
365
+ if (sortColumn.value) {
366
+ url.searchParams.append('sort', sortColumn.value)
367
+ url.searchParams.append('order', sortDirection.value)
368
+ }
369
+ }
370
+
371
+ // Add custom query params from prop
372
+ if (props.queryParams) {
373
+ Object.keys(props.queryParams).forEach(key => {
374
+ const value = props.queryParams![key]
375
+ if (value !== null && value !== undefined) {
376
+ url.searchParams.append(key, String(value))
377
+ }
378
+ })
379
+ }
380
+
381
+ // Merge passed params (these override queryParams if there's a conflict)
382
+ Object.keys(params).forEach(key => {
383
+ url.searchParams.set(key, String(params[key]))
384
+ })
385
+
386
+ const response = await fetch(url.toString(), {
387
+ method: 'GET',
388
+ headers: {
389
+ 'Accept': 'application/json',
390
+ 'Content-Type': 'application/json',
391
+ 'X-Requested-With': 'XMLHttpRequest'
392
+ }
393
+ })
394
+
395
+ if (!response.ok) {
396
+ throw new Error(`HTTP error! status: ${response.status}`)
397
+ }
398
+
399
+ let data = await response.json()
400
+
401
+ // Transform DataTables response to internal format
402
+ if (props.protocol === 'datatables') {
403
+ // DataTables response: { draw, recordsTotal, recordsFiltered, data }
404
+ // Transform to Laravel format internally
405
+ const totalRecords = data.recordsFiltered || data.recordsTotal || 0
406
+ const totalPages = Math.ceil(totalRecords / currentPerPage.value)
407
+
408
+ data = {
409
+ data: data.data || [],
410
+ current_page: currentPage.value,
411
+ last_page: totalPages,
412
+ per_page: currentPerPage.value,
413
+ total: data.recordsFiltered || 0,
414
+ from: totalRecords > 0 ? ((currentPage.value - 1) * currentPerPage.value) + 1 : 0,
415
+ to: Math.min(currentPage.value * currentPerPage.value, totalRecords)
416
+ }
417
+
418
+ // Increment draw counter for next request
419
+ drawCounter.value++
420
+ }
421
+
422
+ internalData.value = data
423
+
424
+ // Store in cache if enabled
425
+ if (props.enableCache) {
426
+ responseCache.value.set(cacheKey, data)
427
+ }
428
+ } catch (error) {
429
+ console.error('Failed to fetch table data', error)
430
+ } finally {
431
+ isLoading.value = false
432
+ }
433
+ } else if (isServerSide.value) {
434
+ router.visit(window.location.pathname, {
435
+ data: {
436
+ page: params.page ?? currentPage.value,
437
+ per_page: currentPerPage.value,
438
+ search: params.search ?? searchQuery.value,
439
+ sort: params.sort ?? sortColumn.value,
440
+ order: params.order ?? sortDirection.value,
441
+ ...(props.queryParams || {})
442
+ },
443
+ preserveState: true,
444
+ preserveScroll: true,
445
+ replace: true,
446
+ onStart: () => isLoading.value = true,
447
+ onFinish: () => isLoading.value = false
448
+ })
449
+ }
450
+ }
451
+
452
+ // ...
453
+
454
+
455
+
456
+ // ...
457
+
458
+ // -- Template for Pagination --
459
+ /*
460
+ <div class="flex items-center justify-between px-2">
461
+ <!-- Left side: Meta + Page Size -->
462
+ <div class="flex items-center gap-6">
463
+ <div class="text-sm text-muted-foreground">
464
+ Showing {{ paginationMeta.from }} to {{ paginationMeta.to }} of {{ paginationMeta.total }} results
465
+ </div>
466
+ <div class="flex items-center space-x-2">
467
+ <p class="text-sm font-medium hidden sm:block">Rows per page</p>
468
+ <Select
469
+ :model-value="String(currentPerPage)"
470
+ @update:model-value="handlePageSizeChange"
471
+ >
472
+ <SelectTrigger class="h-8 w-[70px]">
473
+ <SelectValue :placeholder="String(currentPerPage)" />
474
+ </SelectTrigger>
475
+ <SelectContent side="top">
476
+ <SelectItem
477
+ v-for="pageSize in pageSizes"
478
+ :key="pageSize"
479
+ :value="String(pageSize)"
480
+ >
481
+ {{ pageSize }}
482
+ </SelectItem>
483
+ </SelectContent>
484
+ </Select>
485
+ </div>
486
+ </div>
487
+
488
+ <!-- Right side: Nav Buttons -->
489
+ <div class="flex items-center space-x-2">
490
+ ... buttons ...
491
+ </div>
492
+ </div>
493
+ */
494
+
495
+ // -- Actions --
496
+
497
+ const debouncedSearch = useDebounceFn((value: string) => {
498
+ if (isServerSide.value) {
499
+ if (!props.fetchUrl) {
500
+ // Reset page for Inertia
501
+ // We do this manually here because fetchData logic is slightly different
502
+ fetchData({ search: value, page: 1 })
503
+ } else {
504
+ currentPage.value = 1
505
+ fetchData()
506
+ }
507
+ } else {
508
+ currentPage.value = 1
509
+ }
510
+ emit('update:search', value)
511
+ }, 300)
512
+
513
+ watch(searchQuery, (val) => {
514
+ debouncedSearch(val)
515
+ })
516
+
517
+ function handleSort(col: any) {
518
+ // Determine the actual column to sort by
519
+ // If sortable is a string, use it; otherwise use the column key
520
+ const sortKey = typeof col.sortable === 'string' ? col.sortable : col.key
521
+
522
+ if (sortColumn.value === sortKey) {
523
+ sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
524
+ } else {
525
+ sortColumn.value = sortKey
526
+ sortDirection.value = 'asc'
527
+ }
528
+
529
+ if (isServerSide.value) {
530
+ fetchData({ sort: sortColumn.value, order: sortDirection.value })
531
+ }
532
+
533
+ emit('update:sort', { column: sortColumn.value, direction: sortDirection.value })
534
+ }
535
+
536
+ function handlePageSizeChange(size: any) {
537
+ currentPerPage.value = Number(size)
538
+ currentPage.value = 1
539
+ fetchData()
540
+ }
541
+
542
+ function handlePageChange(page: number) {
543
+ if (page < 1 || page > totalPages.value) return
544
+
545
+ currentPage.value = page
546
+
547
+ if (isServerSide.value) {
548
+ fetchData({ page: page })
549
+ }
550
+
551
+ emit('page-change', page)
552
+ }
553
+
554
+ function refresh() {
555
+ currentPage.value = 1
556
+ fetchData()
557
+ }
558
+
559
+ onMounted(() => {
560
+ if (props.fetchUrl) {
561
+ fetchData()
562
+ }
563
+ })
564
+
565
+ defineExpose({
566
+ refresh,
567
+ fetchData, // exposing fetchData too just in case
568
+ clearCache // expose cache clearing method
569
+ })
570
+
571
+ // -- Helper Styles --
572
+ function getCellClass(col: any, index: number, totalCols: number, rowIndex: number = -1) {
573
+ let classes = col.class || ''
574
+
575
+ // Base classes
576
+ // Removed whitespace-nowrap to allow wrapping
577
+
578
+ if (col.fixed) {
579
+ // Sticky logic
580
+ const isLast = index === totalCols - 1
581
+
582
+ let stickyClass = ''
583
+ if (isLast) {
584
+ // Right sticky: Stronger shadow to the left, and a left border
585
+ stickyClass = ' sticky right-0 z-10 shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-border/50'
586
+ } else {
587
+ // Left sticky: Stronger shadow to the right, and a right border
588
+ stickyClass = ' sticky left-0 z-10 shadow-[4px_0_8px_-2px_rgba(0,0,0,0.1)] border-r border-border/50'
589
+ }
590
+
591
+ // Determine background
592
+ // Sticky cells need opaque bg. We rely on the props.
593
+ let bgClass = 'bg-background' // Fallback
594
+
595
+ if (rowIndex !== -1) {
596
+ // Body Row
597
+ const isEven = rowIndex % 2 === 0
598
+ bgClass = isEven ? props.evenRowColor : props.oddRowColor
599
+
600
+ // Should also match hover
601
+ // If the row has a hover class (like hover:bg-muted), the sticky cell needs group-hover:bg-muted to match.
602
+ // We assume hoverColor is passed as 'hover:bg-...'
603
+ // We try to convert 'hover:bg-...' to 'group-hover:bg-...'
604
+ if (props.hoverColor) {
605
+ const hoverParts = props.hoverColor.split(':')
606
+ if (hoverParts.length > 1) {
607
+ // e.g. ['hover', 'bg-blue-100'] -> 'group-hover:bg-blue-100'
608
+ bgClass += ` group-hover:${hoverParts[1]}`
609
+ // Also handle things like 'hover:bg-muted/50'
610
+ if (hoverParts.length > 2) {
611
+ bgClass = bgClass + ':' + hoverParts.slice(2).join(':')
612
+ }
613
+ }
614
+ }
615
+ } else {
616
+ // Header Row
617
+ bgClass = 'bg-background'
618
+ }
619
+
620
+ classes += stickyClass + ' ' + bgClass
621
+ }
622
+ return classes
623
+ }
624
+
625
+ function getCellStyle(col: any) {
626
+ if (col.width) {
627
+ return { width: col.width, minWidth: col.width, maxWidth: col.width }
628
+ }
629
+ return {}
630
+ }
631
+
632
+ </script>
633
+
634
+ <template>
635
+ <div class="space-y-4">
636
+ <!-- Toolbar -->
637
+ <div v-if="searchable" class="flex flex-col sm:flex-row items-center justify-between gap-4">
638
+ <div v-if="searchable" class="relative w-full sm:max-w-sm flex items-center gap-2">
639
+ <!-- Page Size Select (Z-Index increased to sit above siblings) -->
640
+ <!-- Page Size Select (Native & Styled) -->
641
+ <div class="flex items-center gap-2 shrink-0 relative z-20">
642
+ <span class="text-sm text-muted-foreground whitespace-nowrap hidden sm:inline">Rows</span>
643
+ <div class="relative">
644
+ <select
645
+ :value="currentPerPage"
646
+ @change="(e: any) => handlePageSizeChange(e.target.value)"
647
+ class="h-10 w-[80px] appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
648
+ >
649
+ <option
650
+ v-for="pageSize in normalizedPageSizes"
651
+ :key="pageSize.value"
652
+ :value="pageSize.value"
653
+ >
654
+ {{ pageSize.label }}
655
+ </option>
656
+ </select>
657
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute right-2 top-3 h-4 w-4 opacity-50 pointer-events-none"><path d="m6 9 6 6 6-6"/></svg>
658
+ </div>
659
+ </div>
660
+
661
+ <!-- Search Input -->
662
+ <div class="relative w-full z-10">
663
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
664
+ <input
665
+ v-model="searchQuery"
666
+ type="text"
667
+ placeholder="Search..."
668
+ class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-8 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
669
+ />
670
+ </div>
671
+ </div>
672
+ <div class="flex flex-wrap items-center gap-2 w-full sm:w-auto sm:ml-auto justify-start sm:justify-end">
673
+ <slot name="actions" :rows="tableData" :columns="columns" />
674
+ </div>
675
+ </div>
676
+
677
+ <!-- Table -->
678
+ <div class="rounded-md border bg-background overflow-x-auto relative">
679
+ <!-- We add min-w-full to Table to ensure it stretches -->
680
+ <Table class="min-w-full table-fixed">
681
+ <TableHeader>
682
+ <TableRow>
683
+ <TableHead
684
+ v-for="(col, idx) in columns"
685
+ :key="col.key"
686
+ :class="getCellClass(col, idx, columns.length)"
687
+ :style="getCellStyle(col)"
688
+ >
689
+ <div
690
+ v-if="col.sortable"
691
+ class="flex items-center space-x-2 cursor-pointer select-none hover:text-foreground"
692
+ @click="handleSort(col)"
693
+ >
694
+ <div>{{ col.label }}</div>
695
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 opacity-50 flex-shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
696
+ </div>
697
+ <div v-else>{{ col.label }}</div>
698
+ </TableHead>
699
+ </TableRow>
700
+ </TableHeader>
701
+ <TableBody>
702
+ <template v-if="isLoading">
703
+ <TableRow>
704
+ <TableCell :colspan="columns.length" class="h-24 text-center">
705
+ <div class="flex items-center justify-center">
706
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-6 w-6 animate-spin text-muted-foreground"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
707
+ </div>
708
+ </TableCell>
709
+ </TableRow>
710
+ </template>
711
+ <template v-else-if="tableData.length">
712
+ <TableRow
713
+ v-for="(row, idx) in tableData"
714
+ :key="idx"
715
+ class="group"
716
+ :class="[{ [evenRowColor]: Number(idx) % 2 === 0, [oddRowColor]: Number(idx) % 2 !== 0 }, hoverColor]"
717
+ >
718
+ <TableCell
719
+ v-for="(col, cIdx) in columns"
720
+ :key="col.key"
721
+ :class="getCellClass(col, cIdx, columns.length, Number(idx))"
722
+ :style="getCellStyle(col)"
723
+ >
724
+ <!-- Scoped Slot for custom cell rendering -->
725
+ <div>
726
+ <slot :name="`cell-${col.key}`" :row="row">
727
+ {{ row[col.key] }}
728
+ </slot>
729
+ </div>
730
+ </TableCell>
731
+ </TableRow>
732
+ </template>
733
+ <TableRow v-else>
734
+ <TableCell :colspan="columns.length" class="h-24 text-center">
735
+ No results.
736
+ </TableCell>
737
+ </TableRow>
738
+ </TableBody>
739
+ </Table>
740
+ </div>
741
+
742
+ <!-- Pagination -->
743
+ <div class="flex items-center justify-between px-2">
744
+ <div class="text-sm text-muted-foreground">
745
+ Showing {{ paginationMeta.from }} to {{ paginationMeta.to }} of {{ paginationMeta.total }} results
746
+ </div>
747
+ <div class="flex items-center space-x-1">
748
+ <!-- Previous Button -->
749
+ <button
750
+ class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-3"
751
+ :disabled="(isServerSide ? serverMeta?.current_page === 1 : currentPage === 1)"
752
+ @click="handlePageChange(isServerSide ? (serverMeta?.current_page || 1) - 1 : currentPage - 1)"
753
+ >
754
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m15 18-6-6 6-6"/></svg>
755
+ <span class="ml-1 hidden sm:inline">Previous</span>
756
+ </button>
757
+
758
+ <!-- Page Number Buttons -->
759
+ <template v-for="(page, index) in pageNumbers" :key="index">
760
+ <!-- Ellipsis -->
761
+ <span
762
+ v-if="page === '...'"
763
+ class="inline-flex items-center justify-center h-9 px-3 text-sm text-muted-foreground"
764
+ >
765
+ ...
766
+ </span>
767
+
768
+ <!-- Page Number Button -->
769
+ <button
770
+ v-else
771
+ class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border h-9 min-w-[36px] px-3"
772
+ :class="[
773
+ (isServerSide ? serverMeta?.current_page === page : currentPage === page)
774
+ ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90'
775
+ : 'border-input bg-background hover:bg-accent hover:text-accent-foreground'
776
+ ]"
777
+ @click="handlePageChange(page as number)"
778
+ >
779
+ {{ page }}
780
+ </button>
781
+ </template>
782
+
783
+ <!-- Next Button -->
784
+ <button
785
+ class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-3"
786
+ :disabled="(isServerSide ? serverMeta?.current_page === serverMeta?.last_page : currentPage === totalPages)"
787
+ @click="handlePageChange(isServerSide ? (serverMeta?.current_page || 1) + 1 : currentPage + 1)"
788
+ >
789
+ <span class="mr-1 hidden sm:inline">Next</span>
790
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m9 18 6-6-6-6"/></svg>
791
+ </button>
792
+ </div>
793
+ </div>
794
+ </div>
795
+ </template>