@kikiloaw/simple-table 1.0.1 β 1.0.2
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.
- package/README.md +294 -0
- package/package.json +1 -1
- package/src/SimpleTable.vue +108 -41
package/README.md
CHANGED
|
@@ -212,6 +212,14 @@ const columns = [
|
|
|
212
212
|
label: 'Email'
|
|
213
213
|
},
|
|
214
214
|
|
|
215
|
+
// Auto-numbering (row numbers instead of data)
|
|
216
|
+
{
|
|
217
|
+
key: 'id',
|
|
218
|
+
label: '#',
|
|
219
|
+
autonumber: true,
|
|
220
|
+
width: '80px'
|
|
221
|
+
},
|
|
222
|
+
|
|
215
223
|
// Sticky actions column (always visible)
|
|
216
224
|
{
|
|
217
225
|
key: 'actions',
|
|
@@ -233,6 +241,50 @@ Use custom sort keys when:
|
|
|
233
241
|
|
|
234
242
|
## π¨ Features
|
|
235
243
|
|
|
244
|
+
### Auto-Numbering
|
|
245
|
+
|
|
246
|
+
**Display sequential row numbers instead of actual data:**
|
|
247
|
+
|
|
248
|
+
```vue
|
|
249
|
+
const columns = [
|
|
250
|
+
{
|
|
251
|
+
key: 'id',
|
|
252
|
+
label: '#',
|
|
253
|
+
autonumber: true,
|
|
254
|
+
width: '80px'
|
|
255
|
+
},
|
|
256
|
+
// ... other columns
|
|
257
|
+
]
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Features:**
|
|
261
|
+
- β
Displays 1, 2, 3, 4... for each data row
|
|
262
|
+
- β
Skips group headers (only counts data rows)
|
|
263
|
+
- β
Pagination-aware: Page 2 shows 11, 12, 13... (with 10 per page)
|
|
264
|
+
- β
Overrides actual column data
|
|
265
|
+
- β
Works with both server-side and client-side modes
|
|
266
|
+
|
|
267
|
+
**Example:**
|
|
268
|
+
```
|
|
269
|
+
ββββββ¬βββββββββββββββββββ¬ββββββββββ
|
|
270
|
+
β # β Name β Status β
|
|
271
|
+
ββββββΌβββββββββββββββββββΌββββββββββ€
|
|
272
|
+
β ACTIVE USERS β β Header (not counted)
|
|
273
|
+
β 1 β John Doe β Active β
|
|
274
|
+
β 2 β Jane Smith β Active β
|
|
275
|
+
ββββββ΄βββββββββββββββββββ΄ββββββββββ€
|
|
276
|
+
β INACTIVE USERS β β Header (not counted)
|
|
277
|
+
β 3 β Bob Johnson β Inactiveβ
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Perfect for:**
|
|
281
|
+
- Sequential numbering regardless of actual IDs
|
|
282
|
+
- User-friendly row references
|
|
283
|
+
- Tables with group headers
|
|
284
|
+
- Paginated lists with continuous numbering
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
236
288
|
### Custom Sort Keys
|
|
237
289
|
|
|
238
290
|
**Problem:** You want to display `department.name` but sort by `department_id`.
|
|
@@ -456,6 +508,248 @@ const downloadCSV = (content, filename) => {
|
|
|
456
508
|
- β
Integration with third-party libraries
|
|
457
509
|
|
|
458
510
|
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
### Group Headers
|
|
514
|
+
|
|
515
|
+
**Organize your table data with full-width group headers:**
|
|
516
|
+
|
|
517
|
+
```vue
|
|
518
|
+
<script setup>
|
|
519
|
+
const addGroupHeaders = (rows) => {
|
|
520
|
+
// Sort by category first
|
|
521
|
+
const sorted = [...rows].sort((a, b) =>
|
|
522
|
+
(a.category || '').localeCompare(b.category || '')
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
const result = []
|
|
526
|
+
let currentCategory = null
|
|
527
|
+
|
|
528
|
+
sorted.forEach(row => {
|
|
529
|
+
const category = row.category || 'Uncategorized'
|
|
530
|
+
|
|
531
|
+
// When category changes, add a header row
|
|
532
|
+
if (category !== currentCategory) {
|
|
533
|
+
result.push({
|
|
534
|
+
_isGroupHeader: true, // Special flag
|
|
535
|
+
_groupTitle: category, // Header text
|
|
536
|
+
// Add empty values for all columns
|
|
537
|
+
...Object.fromEntries(
|
|
538
|
+
Object.keys(row).map(key => [key, ''])
|
|
539
|
+
)
|
|
540
|
+
})
|
|
541
|
+
currentCategory = category
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
result.push(row)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
return result
|
|
548
|
+
}
|
|
549
|
+
</script>
|
|
550
|
+
|
|
551
|
+
<template>
|
|
552
|
+
<SimpleTable
|
|
553
|
+
:columns="columns"
|
|
554
|
+
:before-render="addGroupHeaders"
|
|
555
|
+
odd-row-color="bg-gray-50"
|
|
556
|
+
even-row-color="bg-white"
|
|
557
|
+
fetch-url="/api/data"
|
|
558
|
+
/>
|
|
559
|
+
</template>
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
**Result:**
|
|
563
|
+
```
|
|
564
|
+
ββββββββββββββββββββββββββββββββββββββββββ
|
|
565
|
+
β CATEGORY A β β Full-width header
|
|
566
|
+
βββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββ€
|
|
567
|
+
β 1 β Item 1 β $100 β Active β
|
|
568
|
+
β 2 β Item 2 β $200 β Active β
|
|
569
|
+
βββββββ΄βββββββββββ΄βββββββββββ΄βββββββββββββ€
|
|
570
|
+
β CATEGORY B β β Full-width header
|
|
571
|
+
βββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββ€
|
|
572
|
+
β 3 β Item 3 β $150 β Inactive β
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**How It Works:**
|
|
576
|
+
1. Use `beforeRender` callback to transform data
|
|
577
|
+
2. Insert rows with `_isGroupHeader: true` flag
|
|
578
|
+
3. Component renders these as full-width cells with `colspan`
|
|
579
|
+
4. Striping continues correctly across all rows
|
|
580
|
+
|
|
581
|
+
**Grouping Examples:**
|
|
582
|
+
|
|
583
|
+
```vue
|
|
584
|
+
// Group by first letter
|
|
585
|
+
const groupByLetter = (rows) => {
|
|
586
|
+
const sorted = [...rows].sort((a, b) =>
|
|
587
|
+
a.name.localeCompare(b.name)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
const result = []
|
|
591
|
+
let currentLetter = null
|
|
592
|
+
|
|
593
|
+
sorted.forEach(row => {
|
|
594
|
+
const letter = row.name.charAt(0).toUpperCase()
|
|
595
|
+
|
|
596
|
+
if (letter !== currentLetter) {
|
|
597
|
+
result.push({
|
|
598
|
+
_isGroupHeader: true,
|
|
599
|
+
_groupTitle: letter,
|
|
600
|
+
})
|
|
601
|
+
currentLetter = letter
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
result.push(row)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
return result
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Group by date range
|
|
611
|
+
const groupByDate = (rows) => {
|
|
612
|
+
const sorted = [...rows].sort((a, b) =>
|
|
613
|
+
new Date(b.created_at) - new Date(a.created_at)
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
const result = []
|
|
617
|
+
let currentMonth = null
|
|
618
|
+
|
|
619
|
+
sorted.forEach(row => {
|
|
620
|
+
const month = new Date(row.created_at).toLocaleDateString('en', {
|
|
621
|
+
year: 'numeric',
|
|
622
|
+
month: 'long'
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
if (month !== currentMonth) {
|
|
626
|
+
result.push({
|
|
627
|
+
_isGroupHeader: true,
|
|
628
|
+
_groupTitle: month,
|
|
629
|
+
})
|
|
630
|
+
currentMonth = month
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
result.push(row)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
return result
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Group by status
|
|
640
|
+
const groupByStatus = (rows) => {
|
|
641
|
+
const active = rows.filter(r => r.is_active)
|
|
642
|
+
const inactive = rows.filter(r => !r.is_active)
|
|
643
|
+
|
|
644
|
+
return [
|
|
645
|
+
{ _isGroupHeader: true, _groupTitle: 'Active Items' },
|
|
646
|
+
...active,
|
|
647
|
+
{ _isGroupHeader: true, _groupTitle: 'Inactive Items' },
|
|
648
|
+
...inactive
|
|
649
|
+
]
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
**Styling Group Headers:**
|
|
654
|
+
|
|
655
|
+
The component applies these classes to group header rows:
|
|
656
|
+
- `border-b border-gray-200` - Bottom border
|
|
657
|
+
- Font styling via the inner div
|
|
658
|
+
- Row striping colors (odd-row-color / even-row-color)
|
|
659
|
+
|
|
660
|
+
You can customize by overriding row colors:
|
|
661
|
+
```vue
|
|
662
|
+
<SimpleTable
|
|
663
|
+
:before-render="addGroupHeaders"
|
|
664
|
+
odd-row-color="bg-blue-50"
|
|
665
|
+
even-row-color="bg-white"
|
|
666
|
+
/>
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
---
|
|
670
|
+
|
|
671
|
+
### Data Transformation (beforeRender)
|
|
672
|
+
|
|
673
|
+
**Transform data before it's rendered in the table:**
|
|
674
|
+
|
|
675
|
+
```vue
|
|
676
|
+
<script setup>
|
|
677
|
+
import dayjs from 'dayjs'
|
|
678
|
+
|
|
679
|
+
const transformData = (rows) => {
|
|
680
|
+
return rows.map(row => ({
|
|
681
|
+
...row,
|
|
682
|
+
// Add computed properties
|
|
683
|
+
full_name: `${row.first_name} ${row.last_name}`,
|
|
684
|
+
|
|
685
|
+
// Format dates
|
|
686
|
+
created_at_formatted: dayjs(row.created_at).format('MMM D, YYYY'),
|
|
687
|
+
|
|
688
|
+
// Add status badge classes
|
|
689
|
+
status_class: row.status === 'active' ? 'text-green-600' : 'text-red-600',
|
|
690
|
+
|
|
691
|
+
// Transform arrays
|
|
692
|
+
tags_joined: row.tags?.join(', ') || 'No tags',
|
|
693
|
+
|
|
694
|
+
// Add custom logic
|
|
695
|
+
is_urgent: row.priority > 8,
|
|
696
|
+
days_since_created: dayjs().diff(dayjs(row.created_at), 'days')
|
|
697
|
+
}))
|
|
698
|
+
}
|
|
699
|
+
</script>
|
|
700
|
+
|
|
701
|
+
<template>
|
|
702
|
+
<SimpleTable
|
|
703
|
+
:columns="columns"
|
|
704
|
+
fetch-url="/api/users"
|
|
705
|
+
:before-render="transformData"
|
|
706
|
+
/>
|
|
707
|
+
</template>
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
**When to Use:**
|
|
711
|
+
- β
Format dates, numbers, or currencies
|
|
712
|
+
- β
Combine multiple fields into one
|
|
713
|
+
- β
Add computed properties
|
|
714
|
+
- β
Transform nested objects to flat properties
|
|
715
|
+
- β
Add CSS classes based on data
|
|
716
|
+
- β
Filter unwanted rows (return modified array)
|
|
717
|
+
|
|
718
|
+
**Example - Adding Full Names:**
|
|
719
|
+
```vue
|
|
720
|
+
<script setup>
|
|
721
|
+
const columns = [
|
|
722
|
+
{ key: 'full_name', label: 'Name' }, // Not in API response
|
|
723
|
+
{ key: 'email', label: 'Email' },
|
|
724
|
+
{ key: 'created_at_formatted', label: 'Joined' }
|
|
725
|
+
]
|
|
726
|
+
|
|
727
|
+
const beforeRender = (rows) => {
|
|
728
|
+
return rows.map(row => ({
|
|
729
|
+
...row,
|
|
730
|
+
full_name: `${row.first_name} ${row.last_name}`,
|
|
731
|
+
created_at_formatted: new Date(row.created_at).toLocaleDateString()
|
|
732
|
+
}))
|
|
733
|
+
}
|
|
734
|
+
</script>
|
|
735
|
+
|
|
736
|
+
<template>
|
|
737
|
+
<SimpleTable
|
|
738
|
+
:columns="columns"
|
|
739
|
+
:before-render="beforeRender"
|
|
740
|
+
fetch-url="/api/users"
|
|
741
|
+
/>
|
|
742
|
+
</template>
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
**Execution Order:**
|
|
746
|
+
1. Data fetched from API
|
|
747
|
+
2. Filtering & Searching (client-side only)
|
|
748
|
+
3. Sorting (client-side only)
|
|
749
|
+
4. Pagination (client-side only)
|
|
750
|
+
5. **`beforeRender` called** β Your transformation here
|
|
751
|
+
6. Rows rendered in table
|
|
752
|
+
|
|
459
753
|
---
|
|
460
754
|
|
|
461
755
|
### Custom Cell Rendering
|
package/package.json
CHANGED
package/src/SimpleTable.vue
CHANGED
|
@@ -26,6 +26,7 @@ interface Props {
|
|
|
26
26
|
class?: string
|
|
27
27
|
fixed?: boolean // 'left' or 'right' could be added later, assuming 'right' for actions usually
|
|
28
28
|
width?: string
|
|
29
|
+
autonumber?: boolean // If true, display auto-incremented row numbers instead of data
|
|
29
30
|
}[]
|
|
30
31
|
mode?: 'auto' | 'server' | 'client'
|
|
31
32
|
protocol?: 'laravel' | 'datatables' // API request/response format
|
|
@@ -34,6 +35,9 @@ interface Props {
|
|
|
34
35
|
pageSizes?: any[] // number[] or { label: string, value: number }[]
|
|
35
36
|
fetchUrl?: string
|
|
36
37
|
|
|
38
|
+
// Callbacks
|
|
39
|
+
beforeRender?: (rows: any[]) => any[] // Transform data before rendering
|
|
40
|
+
|
|
37
41
|
// Cache Props
|
|
38
42
|
enableCache?: boolean // If true, cache responses by page/search/sort to avoid redundant requests
|
|
39
43
|
|
|
@@ -189,6 +193,7 @@ const serverMeta = computed(() => {
|
|
|
189
193
|
return {
|
|
190
194
|
current_page: meta.current_page ?? 1,
|
|
191
195
|
last_page: meta.last_page ?? 1,
|
|
196
|
+
per_page: meta.per_page ?? currentPerPage.value,
|
|
192
197
|
from: meta.from ?? 0,
|
|
193
198
|
to: meta.to ?? 0,
|
|
194
199
|
total: meta.total ?? 0,
|
|
@@ -198,39 +203,52 @@ const serverMeta = computed(() => {
|
|
|
198
203
|
|
|
199
204
|
// -- Computed: Data Normalization --
|
|
200
205
|
const tableData = computed(() => {
|
|
206
|
+
let result: any[] = []
|
|
207
|
+
|
|
201
208
|
if (isServerSide.value) {
|
|
202
209
|
const d = internalData.value as any
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
let items = [...(internalData.value as any[])]
|
|
210
|
+
result = d.data || []
|
|
211
|
+
} else {
|
|
212
|
+
// Client Side Processing
|
|
213
|
+
let items = [...(internalData.value as any[])]
|
|
208
214
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
// 1. Filter
|
|
216
|
+
if (searchQuery.value) {
|
|
217
|
+
const lowerQuery = searchQuery.value.toLowerCase()
|
|
218
|
+
items = items.filter((item) =>
|
|
219
|
+
Object.values(item).some((val) =>
|
|
220
|
+
String(val).toLowerCase().includes(lowerQuery)
|
|
221
|
+
)
|
|
215
222
|
)
|
|
216
|
-
|
|
217
|
-
}
|
|
223
|
+
}
|
|
218
224
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
// 2. Sort
|
|
226
|
+
if (sortColumn.value) {
|
|
227
|
+
items.sort((a, b) => {
|
|
228
|
+
const valA = a[sortColumn.value]
|
|
229
|
+
const valB = b[sortColumn.value]
|
|
230
|
+
if (valA === valB) return 0
|
|
231
|
+
const comparison = valA > valB ? 1 : -1
|
|
232
|
+
return sortDirection.value === 'asc' ? comparison : -comparison
|
|
233
|
+
})
|
|
234
|
+
}
|
|
229
235
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
// 3. Paginate
|
|
237
|
+
const start = (currentPage.value - 1) * currentPerPage.value
|
|
238
|
+
const end = start + currentPerPage.value
|
|
239
|
+
result = items.slice(start, end)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Apply beforeRender callback if provided
|
|
243
|
+
if (props.beforeRender && typeof props.beforeRender === 'function') {
|
|
244
|
+
const transformed = props.beforeRender(result)
|
|
245
|
+
// Ensure callback returns an array
|
|
246
|
+
if (Array.isArray(transformed)) {
|
|
247
|
+
result = transformed
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result
|
|
234
252
|
})
|
|
235
253
|
|
|
236
254
|
const totalPages = computed(() => {
|
|
@@ -532,6 +550,38 @@ function handlePageSizeChange(size: any) {
|
|
|
532
550
|
fetchData()
|
|
533
551
|
}
|
|
534
552
|
|
|
553
|
+
// Get row class with simple alternating stripes (all rows)
|
|
554
|
+
function getRowClass(row: any, idx: number) {
|
|
555
|
+
// Alternate: index 0 = white, index 1 = gray, index 2 = white, etc.
|
|
556
|
+
const isOdd = idx % 2 === 0 // Changed: even index = odd color (white)
|
|
557
|
+
return [
|
|
558
|
+
{ [props.oddRowColor]: isOdd, [props.evenRowColor]: !isOdd },
|
|
559
|
+
row._isGroupHeader ? '' : props.hoverColor // No hover on headers
|
|
560
|
+
]
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Get row number for auto-numbering (excluding group headers)
|
|
564
|
+
function getRowNumber(idx: number): number {
|
|
565
|
+
// Count only data rows before this index
|
|
566
|
+
let dataRowCount = 0
|
|
567
|
+
for (let i = 0; i <= idx; i++) {
|
|
568
|
+
if (!tableData.value[i]?._isGroupHeader) {
|
|
569
|
+
dataRowCount++
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Add offset for pagination
|
|
574
|
+
if (isServerSide.value) {
|
|
575
|
+
const currentPage = serverMeta.value?.current_page || 1
|
|
576
|
+
const perPage = serverMeta.value?.per_page || currentPerPage.value
|
|
577
|
+
const offset = (currentPage - 1) * perPage
|
|
578
|
+
return offset + dataRowCount
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Client-side: just return the count
|
|
582
|
+
return dataRowCount
|
|
583
|
+
}
|
|
584
|
+
|
|
535
585
|
function handlePageChange(page: number) {
|
|
536
586
|
if (page < 1 || page > totalPages.value) return
|
|
537
587
|
|
|
@@ -706,21 +756,38 @@ function getCellStyle(col: any) {
|
|
|
706
756
|
v-for="(row, idx) in tableData"
|
|
707
757
|
:key="idx"
|
|
708
758
|
class="group"
|
|
709
|
-
:class="
|
|
759
|
+
:class="getRowClass(row, idx)"
|
|
710
760
|
>
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
:
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
761
|
+
<!-- Group Header Row: Single cell spanning all columns -->
|
|
762
|
+
<template v-if="row._isGroupHeader">
|
|
763
|
+
<TableCell :colspan="columns.length" class="border-b border-gray-200">
|
|
764
|
+
<div class="px-2 py-2 font-semibold text-gray-700 text-sm uppercase tracking-wide">
|
|
765
|
+
{{ row._groupTitle }}
|
|
766
|
+
</div>
|
|
767
|
+
</TableCell>
|
|
768
|
+
</template>
|
|
769
|
+
|
|
770
|
+
<!-- Regular Data Row: Individual cells -->
|
|
771
|
+
<template v-else>
|
|
772
|
+
<TableCell
|
|
773
|
+
v-for="(col, cIdx) in columns"
|
|
774
|
+
:key="col.key"
|
|
775
|
+
:class="getCellClass(col, cIdx, columns.length, idx)"
|
|
776
|
+
:style="getCellStyle(col)"
|
|
777
|
+
>
|
|
778
|
+
<!-- Auto-numbering or custom cell rendering -->
|
|
779
|
+
<div>
|
|
780
|
+
<template v-if="col.autonumber">
|
|
781
|
+
{{ getRowNumber(idx) }}
|
|
782
|
+
</template>
|
|
783
|
+
<template v-else>
|
|
784
|
+
<slot :name="`cell-${col.key}`" :row="row">
|
|
785
|
+
{{ row[col.key] }}
|
|
786
|
+
</slot>
|
|
787
|
+
</template>
|
|
788
|
+
</div>
|
|
789
|
+
</TableCell>
|
|
790
|
+
</template>
|
|
724
791
|
</TableRow>
|
|
725
792
|
</template>
|
|
726
793
|
<TableRow v-else>
|