@kikiloaw/simple-table 1.0.1 → 1.0.3
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 +595 -0
- package/package.json +1 -1
- package/src/SimpleTable.vue +148 -43
- package/src/components/table/TableCell.vue +13 -2
- package/src/components/table/TableHead.vue +6 -2
package/README.md
CHANGED
|
@@ -87,6 +87,307 @@ public function getData(Request $request)
|
|
|
87
87
|
|
|
88
88
|
---
|
|
89
89
|
|
|
90
|
+
## 📦 Using Predefined/Static Data
|
|
91
|
+
|
|
92
|
+
**Want to use static data instead of an API?** SimpleTable handles this perfectly in client-side mode!
|
|
93
|
+
|
|
94
|
+
### Basic Setup
|
|
95
|
+
|
|
96
|
+
```vue
|
|
97
|
+
<script setup lang="ts">
|
|
98
|
+
import SimpleTable from '@kikiloaw/simple-table'
|
|
99
|
+
|
|
100
|
+
// Define your static data
|
|
101
|
+
const data = [
|
|
102
|
+
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'active' },
|
|
103
|
+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'active' },
|
|
104
|
+
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', status: 'inactive' },
|
|
105
|
+
// ... more rows
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
const columns = [
|
|
109
|
+
{ key: 'id', label: '#', sortable: true, width: '80px' },
|
|
110
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
111
|
+
{ key: 'email', label: 'Email', sortable: true },
|
|
112
|
+
{ key: 'status', label: 'Status', width: '120px' }
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
const pageSizes = [
|
|
116
|
+
{ label: '50 Rows', value: 50 },
|
|
117
|
+
{ label: '100 Rows', value: 100 },
|
|
118
|
+
]
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<SimpleTable
|
|
123
|
+
:data="data"
|
|
124
|
+
:columns="columns"
|
|
125
|
+
:page-sizes="pageSizes"
|
|
126
|
+
:per-page="50"
|
|
127
|
+
mode="client"
|
|
128
|
+
searchable
|
|
129
|
+
/>
|
|
130
|
+
</template>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Key Props for Static Data
|
|
134
|
+
|
|
135
|
+
| Prop | Required | Default | Description |
|
|
136
|
+
|------|----------|---------|-------------|
|
|
137
|
+
| `:data` | **Yes** | `[]` | Your static array of objects |
|
|
138
|
+
| `mode` | **Yes** | `'auto'` | Set to `"client"` for static data |
|
|
139
|
+
| `:per-page` | Recommended | `10` | Initial page size (should match first option in pageSizes) |
|
|
140
|
+
| `:page-sizes` | Optional | `[10,20,30,50,100]` | Available page size options |
|
|
141
|
+
|
|
142
|
+
### ⚠️ Common Pitfalls
|
|
143
|
+
|
|
144
|
+
#### 1. **Don't Mix Static Data with `fetch-url`**
|
|
145
|
+
|
|
146
|
+
❌ **Wrong:**
|
|
147
|
+
```vue
|
|
148
|
+
<!-- This will ignore your static data! -->
|
|
149
|
+
<SimpleTable
|
|
150
|
+
:data="myData"
|
|
151
|
+
fetch-url="/api/users" <!-- ❌ Conflicts with :data -->
|
|
152
|
+
/>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
✅ **Correct:**
|
|
156
|
+
```vue
|
|
157
|
+
<!-- Remove fetch-url when using static data -->
|
|
158
|
+
<SimpleTable
|
|
159
|
+
:data="myData"
|
|
160
|
+
mode="client"
|
|
161
|
+
/>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### 2. **Set Initial Page Size to Match Your Options**
|
|
165
|
+
|
|
166
|
+
❌ **Wrong:**
|
|
167
|
+
```vue
|
|
168
|
+
<!-- Component defaults to 10, but you only have 50/100 options -->
|
|
169
|
+
<SimpleTable
|
|
170
|
+
:data="data"
|
|
171
|
+
:page-sizes="[{ label: '50 Rows', value: 50 }, { label: '100 Rows', value: 100 }]"
|
|
172
|
+
<!-- ❌ Will show "10 Rows" which doesn't exist in dropdown -->
|
|
173
|
+
/>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
✅ **Correct:**
|
|
177
|
+
```vue
|
|
178
|
+
<SimpleTable
|
|
179
|
+
:data="data"
|
|
180
|
+
:page-sizes="[50, 100]"
|
|
181
|
+
:per-page="50" <!-- ✅ Matches first option -->
|
|
182
|
+
/>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### 3. **Columns Must Match Your Data Structure**
|
|
186
|
+
|
|
187
|
+
❌ **Wrong:**
|
|
188
|
+
```vue
|
|
189
|
+
<script setup>
|
|
190
|
+
const data = [
|
|
191
|
+
{ CourseCode: 'CS101', Description: 'Intro to CS', Units: 3 }
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
const columns = [
|
|
195
|
+
{ key: 'course_code', label: 'Code' }, // ❌ Wrong key!
|
|
196
|
+
{ key: 'description', label: 'Name' }, // ❌ Wrong key!
|
|
197
|
+
]
|
|
198
|
+
</script>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
✅ **Correct:**
|
|
202
|
+
```vue
|
|
203
|
+
<script setup>
|
|
204
|
+
const data = [
|
|
205
|
+
{ CourseCode: 'CS101', Description: 'Intro to CS', Units: 3 }
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
const columns = [
|
|
209
|
+
{ key: 'CourseCode', label: 'Code' }, // ✅ Matches data
|
|
210
|
+
{ key: 'Description', label: 'Name' }, // ✅ Matches data
|
|
211
|
+
{ key: 'Units', label: 'Units' },
|
|
212
|
+
]
|
|
213
|
+
</script>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Features Available in Client-Side Mode
|
|
217
|
+
|
|
218
|
+
✅ **Works:**
|
|
219
|
+
- Client-side searching (filters through your data array)
|
|
220
|
+
- Client-side sorting (by sortable columns)
|
|
221
|
+
- Client-side pagination (chunks your data into pages)
|
|
222
|
+
- Data transformation via `beforeRender`
|
|
223
|
+
- Custom cell rendering
|
|
224
|
+
- Group headers
|
|
225
|
+
- Auto-numbering
|
|
226
|
+
|
|
227
|
+
❌ **Not Available:**
|
|
228
|
+
- Server-side sorting (data is sorted locally)
|
|
229
|
+
- API caching (no API calls)
|
|
230
|
+
- Query parameters (no server to send them to)
|
|
231
|
+
|
|
232
|
+
### Complete Example with Group Headers
|
|
233
|
+
|
|
234
|
+
```vue
|
|
235
|
+
<script setup lang="ts">
|
|
236
|
+
import SimpleTable from '@kikiloaw/simple-table'
|
|
237
|
+
|
|
238
|
+
// Static course data
|
|
239
|
+
const data = [
|
|
240
|
+
{
|
|
241
|
+
CourseCode: 'CS101',
|
|
242
|
+
Description: 'Intro to Computer Science',
|
|
243
|
+
Units: 3,
|
|
244
|
+
Grade: 'A',
|
|
245
|
+
semester: '1st Semester, 2023-2024'
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
CourseCode: 'MATH101',
|
|
249
|
+
Description: 'Calculus I',
|
|
250
|
+
Units: 4,
|
|
251
|
+
Grade: 'B+',
|
|
252
|
+
semester: '1st Semester, 2023-2024'
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
CourseCode: 'CS102',
|
|
256
|
+
Description: 'Data Structures',
|
|
257
|
+
Units: 3,
|
|
258
|
+
Grade: 'A-',
|
|
259
|
+
semester: '2nd Semester, 2023-2024'
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
const columns = [
|
|
264
|
+
{ key: 'CourseCode', label: 'Course Code', sortable: true, width: '150px' },
|
|
265
|
+
{ key: 'Description', label: 'Description', sortable: true, width: '300px' },
|
|
266
|
+
{ key: 'Units', label: 'Units', width: '80px' },
|
|
267
|
+
{ key: 'Grade', label: 'Grade', sortable: true, width: '80px' },
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
// Add group headers by semester
|
|
271
|
+
const addGroupHeaders = (rows) => {
|
|
272
|
+
// DON'T sort here if your data is already in the correct order!
|
|
273
|
+
// Sorting will override your predefined order
|
|
274
|
+
|
|
275
|
+
const result = []
|
|
276
|
+
let currentSemester = null
|
|
277
|
+
|
|
278
|
+
rows.forEach(row => {
|
|
279
|
+
const semester = row.semester || 'No Semester'
|
|
280
|
+
|
|
281
|
+
// When semester changes, add a header row
|
|
282
|
+
if (semester !== currentSemester) {
|
|
283
|
+
result.push({
|
|
284
|
+
_isGroupHeader: true,
|
|
285
|
+
_groupTitle: semester,
|
|
286
|
+
// Empty values for all columns
|
|
287
|
+
CourseCode: '',
|
|
288
|
+
Description: semester,
|
|
289
|
+
Units: '',
|
|
290
|
+
Grade: '',
|
|
291
|
+
})
|
|
292
|
+
currentSemester = semester
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
result.push(row)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
300
|
+
</script>
|
|
301
|
+
|
|
302
|
+
<template>
|
|
303
|
+
<SimpleTable
|
|
304
|
+
:data="data"
|
|
305
|
+
:columns="columns"
|
|
306
|
+
:page-sizes="[50, 100]"
|
|
307
|
+
:per-page="50"
|
|
308
|
+
:before-render="addGroupHeaders"
|
|
309
|
+
mode="client"
|
|
310
|
+
searchable
|
|
311
|
+
odd-row-color="bg-gray-50"
|
|
312
|
+
even-row-color="bg-white"
|
|
313
|
+
hover-color="hover:bg-green-100"
|
|
314
|
+
/>
|
|
315
|
+
</template>
|
|
316
|
+
|
|
317
|
+
### 📏 Row Height Control
|
|
318
|
+
|
|
319
|
+
Control the exact height of your table rows with the `rowHeight` prop:
|
|
320
|
+
|
|
321
|
+
```vue
|
|
322
|
+
<template>
|
|
323
|
+
<SimpleTable
|
|
324
|
+
:data="data"
|
|
325
|
+
:columns="columns"
|
|
326
|
+
:row-height="38" <!-- Rows will be exactly 38px tall -->
|
|
327
|
+
/>
|
|
328
|
+
</template>
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**How it works:**
|
|
332
|
+
- Sets the `height` style on both header and data rows
|
|
333
|
+
- Automatically adjusts cell padding based on the height
|
|
334
|
+
- Default: `38px` (compact and readable)
|
|
335
|
+
|
|
336
|
+
**Recommended Values:**
|
|
337
|
+
|
|
338
|
+
| Height | Padding | Use Case |
|
|
339
|
+
|--------|---------|----------|
|
|
340
|
+
| `30-36px` | `p-2` (8px) | **Extra compact** - Maximum rows visible, dense data |
|
|
341
|
+
| `38-42px` | `p-2` (8px) | **Standard** - Good balance (default: 38px) |
|
|
342
|
+
| `44-55px` | `p-3` (12px) | **Comfortable** - Easy to read, spacious |
|
|
343
|
+
| `56px+` | `p-4` (16px) | **Very spacious** - Accessibility-friendly, large text |
|
|
344
|
+
|
|
345
|
+
**Examples:**
|
|
346
|
+
|
|
347
|
+
```vue
|
|
348
|
+
<!-- Ultra compact for dashboards -->
|
|
349
|
+
<SimpleTable :row-height="32" />
|
|
350
|
+
|
|
351
|
+
<!-- Default - balanced -->
|
|
352
|
+
<SimpleTable :row-height="38" /> <!-- or omit for default -->
|
|
353
|
+
|
|
354
|
+
<!-- Comfortable reading -->
|
|
355
|
+
<SimpleTable :row-height="48" />
|
|
356
|
+
|
|
357
|
+
<!-- Accessibility-friendly -->
|
|
358
|
+
<SimpleTable :row-height="60" />
|
|
359
|
+
|
|
360
|
+
<!-- No prop = uses default 38px -->
|
|
361
|
+
<SimpleTable :data="data" :columns="columns" />
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**Padding Auto-Adjustment:**
|
|
365
|
+
|
|
366
|
+
The component automatically adjusts internal padding:
|
|
367
|
+
- **< 44px**: Uses `p-2` (8px) - Compact
|
|
368
|
+
- **44-55px**: Uses `p-3` (12px) - Normal
|
|
369
|
+
- **56px+**: Uses `p-4` (16px) - Comfortable
|
|
370
|
+
|
|
371
|
+
### 💡 Best Practices
|
|
372
|
+
|
|
373
|
+
1. **Data Order Preservation**
|
|
374
|
+
- If your data is already sorted correctly, **don't sort it again** in `beforeRender`
|
|
375
|
+
- Let users sort by clicking column headers if needed
|
|
376
|
+
|
|
377
|
+
2. **Performance**
|
|
378
|
+
- Client-side mode works great for **< 1,000 rows**
|
|
379
|
+
- For larger datasets, consider server-side mode with `fetch-url`
|
|
380
|
+
|
|
381
|
+
3. **Reactivity**
|
|
382
|
+
- Use `ref()` or `reactive()` if your data changes
|
|
383
|
+
- The table will automatically update when data changes
|
|
384
|
+
|
|
385
|
+
4. **Page Size Options**
|
|
386
|
+
- Keep options reasonable: `[10, 25, 50, 100]`
|
|
387
|
+
- Set `:per-page` to match your first option
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
90
391
|
## 📖 Table of Contents
|
|
91
392
|
|
|
92
393
|
- [Core Concepts](#-core-concepts)
|
|
@@ -212,6 +513,14 @@ const columns = [
|
|
|
212
513
|
label: 'Email'
|
|
213
514
|
},
|
|
214
515
|
|
|
516
|
+
// Auto-numbering (row numbers instead of data)
|
|
517
|
+
{
|
|
518
|
+
key: 'id',
|
|
519
|
+
label: '#',
|
|
520
|
+
autonumber: true,
|
|
521
|
+
width: '80px'
|
|
522
|
+
},
|
|
523
|
+
|
|
215
524
|
// Sticky actions column (always visible)
|
|
216
525
|
{
|
|
217
526
|
key: 'actions',
|
|
@@ -233,6 +542,50 @@ Use custom sort keys when:
|
|
|
233
542
|
|
|
234
543
|
## 🎨 Features
|
|
235
544
|
|
|
545
|
+
### Auto-Numbering
|
|
546
|
+
|
|
547
|
+
**Display sequential row numbers instead of actual data:**
|
|
548
|
+
|
|
549
|
+
```vue
|
|
550
|
+
const columns = [
|
|
551
|
+
{
|
|
552
|
+
key: 'id',
|
|
553
|
+
label: '#',
|
|
554
|
+
autonumber: true,
|
|
555
|
+
width: '80px'
|
|
556
|
+
},
|
|
557
|
+
// ... other columns
|
|
558
|
+
]
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Features:**
|
|
562
|
+
- ✅ Displays 1, 2, 3, 4... for each data row
|
|
563
|
+
- ✅ Skips group headers (only counts data rows)
|
|
564
|
+
- ✅ Pagination-aware: Page 2 shows 11, 12, 13... (with 10 per page)
|
|
565
|
+
- ✅ Overrides actual column data
|
|
566
|
+
- ✅ Works with both server-side and client-side modes
|
|
567
|
+
|
|
568
|
+
**Example:**
|
|
569
|
+
```
|
|
570
|
+
┌────┬──────────────────┬─────────┐
|
|
571
|
+
│ # │ Name │ Status │
|
|
572
|
+
├────┼──────────────────┼─────────┤
|
|
573
|
+
│ ACTIVE USERS │ ← Header (not counted)
|
|
574
|
+
│ 1 │ John Doe │ Active │
|
|
575
|
+
│ 2 │ Jane Smith │ Active │
|
|
576
|
+
├────┴──────────────────┴─────────┤
|
|
577
|
+
│ INACTIVE USERS │ ← Header (not counted)
|
|
578
|
+
│ 3 │ Bob Johnson │ Inactive│
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
**Perfect for:**
|
|
582
|
+
- Sequential numbering regardless of actual IDs
|
|
583
|
+
- User-friendly row references
|
|
584
|
+
- Tables with group headers
|
|
585
|
+
- Paginated lists with continuous numbering
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
236
589
|
### Custom Sort Keys
|
|
237
590
|
|
|
238
591
|
**Problem:** You want to display `department.name` but sort by `department_id`.
|
|
@@ -456,6 +809,248 @@ const downloadCSV = (content, filename) => {
|
|
|
456
809
|
- ✅ Integration with third-party libraries
|
|
457
810
|
|
|
458
811
|
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
### Group Headers
|
|
815
|
+
|
|
816
|
+
**Organize your table data with full-width group headers:**
|
|
817
|
+
|
|
818
|
+
```vue
|
|
819
|
+
<script setup>
|
|
820
|
+
const addGroupHeaders = (rows) => {
|
|
821
|
+
// Sort by category first
|
|
822
|
+
const sorted = [...rows].sort((a, b) =>
|
|
823
|
+
(a.category || '').localeCompare(b.category || '')
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
const result = []
|
|
827
|
+
let currentCategory = null
|
|
828
|
+
|
|
829
|
+
sorted.forEach(row => {
|
|
830
|
+
const category = row.category || 'Uncategorized'
|
|
831
|
+
|
|
832
|
+
// When category changes, add a header row
|
|
833
|
+
if (category !== currentCategory) {
|
|
834
|
+
result.push({
|
|
835
|
+
_isGroupHeader: true, // Special flag
|
|
836
|
+
_groupTitle: category, // Header text
|
|
837
|
+
// Add empty values for all columns
|
|
838
|
+
...Object.fromEntries(
|
|
839
|
+
Object.keys(row).map(key => [key, ''])
|
|
840
|
+
)
|
|
841
|
+
})
|
|
842
|
+
currentCategory = category
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
result.push(row)
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
return result
|
|
849
|
+
}
|
|
850
|
+
</script>
|
|
851
|
+
|
|
852
|
+
<template>
|
|
853
|
+
<SimpleTable
|
|
854
|
+
:columns="columns"
|
|
855
|
+
:before-render="addGroupHeaders"
|
|
856
|
+
odd-row-color="bg-gray-50"
|
|
857
|
+
even-row-color="bg-white"
|
|
858
|
+
fetch-url="/api/data"
|
|
859
|
+
/>
|
|
860
|
+
</template>
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
**Result:**
|
|
864
|
+
```
|
|
865
|
+
┌────────────────────────────────────────┐
|
|
866
|
+
│ CATEGORY A │ ← Full-width header
|
|
867
|
+
├─────┬──────────┬──────────┬────────────┤
|
|
868
|
+
│ 1 │ Item 1 │ $100 │ Active │
|
|
869
|
+
│ 2 │ Item 2 │ $200 │ Active │
|
|
870
|
+
├─────┴──────────┴──────────┴────────────┤
|
|
871
|
+
│ CATEGORY B │ ← Full-width header
|
|
872
|
+
├─────┬──────────┬──────────┬────────────┤
|
|
873
|
+
│ 3 │ Item 3 │ $150 │ Inactive │
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
**How It Works:**
|
|
877
|
+
1. Use `beforeRender` callback to transform data
|
|
878
|
+
2. Insert rows with `_isGroupHeader: true` flag
|
|
879
|
+
3. Component renders these as full-width cells with `colspan`
|
|
880
|
+
4. Striping continues correctly across all rows
|
|
881
|
+
|
|
882
|
+
**Grouping Examples:**
|
|
883
|
+
|
|
884
|
+
```vue
|
|
885
|
+
// Group by first letter
|
|
886
|
+
const groupByLetter = (rows) => {
|
|
887
|
+
const sorted = [...rows].sort((a, b) =>
|
|
888
|
+
a.name.localeCompare(b.name)
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
const result = []
|
|
892
|
+
let currentLetter = null
|
|
893
|
+
|
|
894
|
+
sorted.forEach(row => {
|
|
895
|
+
const letter = row.name.charAt(0).toUpperCase()
|
|
896
|
+
|
|
897
|
+
if (letter !== currentLetter) {
|
|
898
|
+
result.push({
|
|
899
|
+
_isGroupHeader: true,
|
|
900
|
+
_groupTitle: letter,
|
|
901
|
+
})
|
|
902
|
+
currentLetter = letter
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
result.push(row)
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
return result
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Group by date range
|
|
912
|
+
const groupByDate = (rows) => {
|
|
913
|
+
const sorted = [...rows].sort((a, b) =>
|
|
914
|
+
new Date(b.created_at) - new Date(a.created_at)
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
const result = []
|
|
918
|
+
let currentMonth = null
|
|
919
|
+
|
|
920
|
+
sorted.forEach(row => {
|
|
921
|
+
const month = new Date(row.created_at).toLocaleDateString('en', {
|
|
922
|
+
year: 'numeric',
|
|
923
|
+
month: 'long'
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
if (month !== currentMonth) {
|
|
927
|
+
result.push({
|
|
928
|
+
_isGroupHeader: true,
|
|
929
|
+
_groupTitle: month,
|
|
930
|
+
})
|
|
931
|
+
currentMonth = month
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
result.push(row)
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
return result
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Group by status
|
|
941
|
+
const groupByStatus = (rows) => {
|
|
942
|
+
const active = rows.filter(r => r.is_active)
|
|
943
|
+
const inactive = rows.filter(r => !r.is_active)
|
|
944
|
+
|
|
945
|
+
return [
|
|
946
|
+
{ _isGroupHeader: true, _groupTitle: 'Active Items' },
|
|
947
|
+
...active,
|
|
948
|
+
{ _isGroupHeader: true, _groupTitle: 'Inactive Items' },
|
|
949
|
+
...inactive
|
|
950
|
+
]
|
|
951
|
+
}
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
**Styling Group Headers:**
|
|
955
|
+
|
|
956
|
+
The component applies these classes to group header rows:
|
|
957
|
+
- `border-b border-gray-200` - Bottom border
|
|
958
|
+
- Font styling via the inner div
|
|
959
|
+
- Row striping colors (odd-row-color / even-row-color)
|
|
960
|
+
|
|
961
|
+
You can customize by overriding row colors:
|
|
962
|
+
```vue
|
|
963
|
+
<SimpleTable
|
|
964
|
+
:before-render="addGroupHeaders"
|
|
965
|
+
odd-row-color="bg-blue-50"
|
|
966
|
+
even-row-color="bg-white"
|
|
967
|
+
/>
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
---
|
|
971
|
+
|
|
972
|
+
### Data Transformation (beforeRender)
|
|
973
|
+
|
|
974
|
+
**Transform data before it's rendered in the table:**
|
|
975
|
+
|
|
976
|
+
```vue
|
|
977
|
+
<script setup>
|
|
978
|
+
import dayjs from 'dayjs'
|
|
979
|
+
|
|
980
|
+
const transformData = (rows) => {
|
|
981
|
+
return rows.map(row => ({
|
|
982
|
+
...row,
|
|
983
|
+
// Add computed properties
|
|
984
|
+
full_name: `${row.first_name} ${row.last_name}`,
|
|
985
|
+
|
|
986
|
+
// Format dates
|
|
987
|
+
created_at_formatted: dayjs(row.created_at).format('MMM D, YYYY'),
|
|
988
|
+
|
|
989
|
+
// Add status badge classes
|
|
990
|
+
status_class: row.status === 'active' ? 'text-green-600' : 'text-red-600',
|
|
991
|
+
|
|
992
|
+
// Transform arrays
|
|
993
|
+
tags_joined: row.tags?.join(', ') || 'No tags',
|
|
994
|
+
|
|
995
|
+
// Add custom logic
|
|
996
|
+
is_urgent: row.priority > 8,
|
|
997
|
+
days_since_created: dayjs().diff(dayjs(row.created_at), 'days')
|
|
998
|
+
}))
|
|
999
|
+
}
|
|
1000
|
+
</script>
|
|
1001
|
+
|
|
1002
|
+
<template>
|
|
1003
|
+
<SimpleTable
|
|
1004
|
+
:columns="columns"
|
|
1005
|
+
fetch-url="/api/users"
|
|
1006
|
+
:before-render="transformData"
|
|
1007
|
+
/>
|
|
1008
|
+
</template>
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
**When to Use:**
|
|
1012
|
+
- ✅ Format dates, numbers, or currencies
|
|
1013
|
+
- ✅ Combine multiple fields into one
|
|
1014
|
+
- ✅ Add computed properties
|
|
1015
|
+
- ✅ Transform nested objects to flat properties
|
|
1016
|
+
- ✅ Add CSS classes based on data
|
|
1017
|
+
- ✅ Filter unwanted rows (return modified array)
|
|
1018
|
+
|
|
1019
|
+
**Example - Adding Full Names:**
|
|
1020
|
+
```vue
|
|
1021
|
+
<script setup>
|
|
1022
|
+
const columns = [
|
|
1023
|
+
{ key: 'full_name', label: 'Name' }, // Not in API response
|
|
1024
|
+
{ key: 'email', label: 'Email' },
|
|
1025
|
+
{ key: 'created_at_formatted', label: 'Joined' }
|
|
1026
|
+
]
|
|
1027
|
+
|
|
1028
|
+
const beforeRender = (rows) => {
|
|
1029
|
+
return rows.map(row => ({
|
|
1030
|
+
...row,
|
|
1031
|
+
full_name: `${row.first_name} ${row.last_name}`,
|
|
1032
|
+
created_at_formatted: new Date(row.created_at).toLocaleDateString()
|
|
1033
|
+
}))
|
|
1034
|
+
}
|
|
1035
|
+
</script>
|
|
1036
|
+
|
|
1037
|
+
<template>
|
|
1038
|
+
<SimpleTable
|
|
1039
|
+
:columns="columns"
|
|
1040
|
+
:before-render="beforeRender"
|
|
1041
|
+
fetch-url="/api/users"
|
|
1042
|
+
/>
|
|
1043
|
+
</template>
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
**Execution Order:**
|
|
1047
|
+
1. Data fetched from API
|
|
1048
|
+
2. Filtering & Searching (client-side only)
|
|
1049
|
+
3. Sorting (client-side only)
|
|
1050
|
+
4. Pagination (client-side only)
|
|
1051
|
+
5. **`beforeRender` called** ← Your transformation here
|
|
1052
|
+
6. Rows rendered in table
|
|
1053
|
+
|
|
459
1054
|
---
|
|
460
1055
|
|
|
461
1056
|
### 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
|
|
|
@@ -41,6 +45,7 @@ interface Props {
|
|
|
41
45
|
queryParams?: Record<string, any> // Additional parameters to send with every request (e.g., filters, user context)
|
|
42
46
|
|
|
43
47
|
// Style Props
|
|
48
|
+
rowHeight?: number // Table row height in pixels (default: 38)
|
|
44
49
|
oddRowColor?: string // Tailwind color class, e.g. 'bg-white'
|
|
45
50
|
evenRowColor?: string // Tailwind color class, e.g. 'bg-gray-50'
|
|
46
51
|
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.
|
|
@@ -56,6 +61,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
56
61
|
queryParams: () => ({}),
|
|
57
62
|
perPage: 10,
|
|
58
63
|
pageSizes: () => [10, 20, 30, 50, 100],
|
|
64
|
+
rowHeight: 38,
|
|
59
65
|
oddRowColor: 'bg-background',
|
|
60
66
|
evenRowColor: 'bg-background',
|
|
61
67
|
hoverColor: 'hover:bg-muted/50'
|
|
@@ -84,6 +90,37 @@ const normalizedPageSizes = computed(() => {
|
|
|
84
90
|
return []
|
|
85
91
|
})
|
|
86
92
|
|
|
93
|
+
// -- Computed: Row height-based sizing --
|
|
94
|
+
const densityConfig = computed(() => {
|
|
95
|
+
const height = props.rowHeight || 38
|
|
96
|
+
|
|
97
|
+
// Calculate padding based on height
|
|
98
|
+
// For 38px height: use p-2 (8px)
|
|
99
|
+
// For 48px height: use p-3 (12px)
|
|
100
|
+
// For 56px+ height: use p-4 (16px)
|
|
101
|
+
let cellPadding = 'p-2'
|
|
102
|
+
let headerPadding = 'px-2'
|
|
103
|
+
let groupHeaderPadding = 'py-1'
|
|
104
|
+
|
|
105
|
+
if (height >= 56) {
|
|
106
|
+
cellPadding = 'p-4'
|
|
107
|
+
headerPadding = 'px-4'
|
|
108
|
+
groupHeaderPadding = 'py-2'
|
|
109
|
+
} else if (height >= 44) {
|
|
110
|
+
cellPadding = 'p-3'
|
|
111
|
+
headerPadding = 'px-3'
|
|
112
|
+
groupHeaderPadding = 'py-1.5'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
cellPadding,
|
|
117
|
+
cellHeight: `${height}px`,
|
|
118
|
+
headerHeight: `h-[${height}px]`,
|
|
119
|
+
headerPadding,
|
|
120
|
+
groupHeaderPadding
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
87
124
|
|
|
88
125
|
|
|
89
126
|
// <template>
|
|
@@ -189,6 +226,7 @@ const serverMeta = computed(() => {
|
|
|
189
226
|
return {
|
|
190
227
|
current_page: meta.current_page ?? 1,
|
|
191
228
|
last_page: meta.last_page ?? 1,
|
|
229
|
+
per_page: meta.per_page ?? currentPerPage.value,
|
|
192
230
|
from: meta.from ?? 0,
|
|
193
231
|
to: meta.to ?? 0,
|
|
194
232
|
total: meta.total ?? 0,
|
|
@@ -198,39 +236,52 @@ const serverMeta = computed(() => {
|
|
|
198
236
|
|
|
199
237
|
// -- Computed: Data Normalization --
|
|
200
238
|
const tableData = computed(() => {
|
|
239
|
+
let result: any[] = []
|
|
240
|
+
|
|
201
241
|
if (isServerSide.value) {
|
|
202
242
|
const d = internalData.value as any
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
let items = [...(internalData.value as any[])]
|
|
243
|
+
result = d.data || []
|
|
244
|
+
} else {
|
|
245
|
+
// Client Side Processing
|
|
246
|
+
let items = [...(internalData.value as any[])]
|
|
208
247
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
// 1. Filter
|
|
249
|
+
if (searchQuery.value) {
|
|
250
|
+
const lowerQuery = searchQuery.value.toLowerCase()
|
|
251
|
+
items = items.filter((item) =>
|
|
252
|
+
Object.values(item).some((val) =>
|
|
253
|
+
String(val).toLowerCase().includes(lowerQuery)
|
|
254
|
+
)
|
|
215
255
|
)
|
|
216
|
-
|
|
217
|
-
}
|
|
256
|
+
}
|
|
218
257
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
258
|
+
// 2. Sort
|
|
259
|
+
if (sortColumn.value) {
|
|
260
|
+
items.sort((a, b) => {
|
|
261
|
+
const valA = a[sortColumn.value]
|
|
262
|
+
const valB = b[sortColumn.value]
|
|
263
|
+
if (valA === valB) return 0
|
|
264
|
+
const comparison = valA > valB ? 1 : -1
|
|
265
|
+
return sortDirection.value === 'asc' ? comparison : -comparison
|
|
266
|
+
})
|
|
267
|
+
}
|
|
229
268
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
269
|
+
// 3. Paginate
|
|
270
|
+
const start = (currentPage.value - 1) * currentPerPage.value
|
|
271
|
+
const end = start + currentPerPage.value
|
|
272
|
+
result = items.slice(start, end)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Apply beforeRender callback if provided
|
|
276
|
+
if (props.beforeRender && typeof props.beforeRender === 'function') {
|
|
277
|
+
const transformed = props.beforeRender(result)
|
|
278
|
+
// Ensure callback returns an array
|
|
279
|
+
if (Array.isArray(transformed)) {
|
|
280
|
+
result = transformed
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return result
|
|
234
285
|
})
|
|
235
286
|
|
|
236
287
|
const totalPages = computed(() => {
|
|
@@ -532,6 +583,38 @@ function handlePageSizeChange(size: any) {
|
|
|
532
583
|
fetchData()
|
|
533
584
|
}
|
|
534
585
|
|
|
586
|
+
// Get row class with simple alternating stripes (all rows)
|
|
587
|
+
function getRowClass(row: any, idx: number) {
|
|
588
|
+
// Alternate: index 0 = white, index 1 = gray, index 2 = white, etc.
|
|
589
|
+
const isOdd = idx % 2 === 0 // Changed: even index = odd color (white)
|
|
590
|
+
return [
|
|
591
|
+
{ [props.oddRowColor]: isOdd, [props.evenRowColor]: !isOdd },
|
|
592
|
+
row._isGroupHeader ? '' : props.hoverColor // No hover on headers
|
|
593
|
+
]
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Get row number for auto-numbering (excluding group headers)
|
|
597
|
+
function getRowNumber(idx: number): number {
|
|
598
|
+
// Count only data rows before this index
|
|
599
|
+
let dataRowCount = 0
|
|
600
|
+
for (let i = 0; i <= idx; i++) {
|
|
601
|
+
if (!tableData.value[i]?._isGroupHeader) {
|
|
602
|
+
dataRowCount++
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Add offset for pagination
|
|
607
|
+
if (isServerSide.value) {
|
|
608
|
+
const currentPage = serverMeta.value?.current_page || 1
|
|
609
|
+
const perPage = serverMeta.value?.per_page || currentPerPage.value
|
|
610
|
+
const offset = (currentPage - 1) * perPage
|
|
611
|
+
return offset + dataRowCount
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Client-side: just return the count
|
|
615
|
+
return dataRowCount
|
|
616
|
+
}
|
|
617
|
+
|
|
535
618
|
function handlePageChange(page: number) {
|
|
536
619
|
if (page < 1 || page > totalPages.value) return
|
|
537
620
|
|
|
@@ -672,12 +755,14 @@ function getCellStyle(col: any) {
|
|
|
672
755
|
<!-- We add min-w-full to Table to ensure it stretches -->
|
|
673
756
|
<Table class="min-w-full table-fixed">
|
|
674
757
|
<TableHeader>
|
|
675
|
-
<TableRow>
|
|
758
|
+
<TableRow :style="{ height: densityConfig.cellHeight }">
|
|
676
759
|
<TableHead
|
|
677
760
|
v-for="(col, idx) in columns"
|
|
678
761
|
:key="col.key"
|
|
679
762
|
:class="getCellClass(col, idx, columns.length)"
|
|
680
763
|
:style="getCellStyle(col)"
|
|
764
|
+
:height="densityConfig.headerHeight"
|
|
765
|
+
:padding="densityConfig.headerPadding"
|
|
681
766
|
>
|
|
682
767
|
<div
|
|
683
768
|
v-if="col.sortable"
|
|
@@ -706,24 +791,44 @@ function getCellStyle(col: any) {
|
|
|
706
791
|
v-for="(row, idx) in tableData"
|
|
707
792
|
:key="idx"
|
|
708
793
|
class="group"
|
|
709
|
-
:class="
|
|
794
|
+
:class="getRowClass(row, idx)"
|
|
795
|
+
:style="{ height: densityConfig.cellHeight }"
|
|
710
796
|
>
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
:
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
797
|
+
<!-- Group Header Row: Single cell spanning all columns -->
|
|
798
|
+
<template v-if="row._isGroupHeader">
|
|
799
|
+
<TableCell :colspan="columns.length" class="border-b border-gray-200">
|
|
800
|
+
<div :class="['px-2', densityConfig.groupHeaderPadding, 'font-semibold text-gray-700 text-sm uppercase tracking-wide']">
|
|
801
|
+
{{ row._groupTitle }}
|
|
802
|
+
</div>
|
|
803
|
+
</TableCell>
|
|
804
|
+
</template>
|
|
805
|
+
|
|
806
|
+
<!-- Regular Data Row: Individual cells -->
|
|
807
|
+
<template v-else>
|
|
808
|
+
<TableCell
|
|
809
|
+
v-for="(col, cIdx) in columns"
|
|
810
|
+
:key="col.key"
|
|
811
|
+
:class="getCellClass(col, cIdx, columns.length, idx)"
|
|
812
|
+
:style="getCellStyle(col)"
|
|
813
|
+
:padding="densityConfig.cellPadding"
|
|
814
|
+
:height="densityConfig.cellHeight"
|
|
815
|
+
>
|
|
816
|
+
<!-- Auto-numbering or custom cell rendering -->
|
|
817
|
+
<div>
|
|
818
|
+
<template v-if="col.autonumber">
|
|
819
|
+
{{ getRowNumber(idx) }}
|
|
820
|
+
</template>
|
|
821
|
+
<template v-else>
|
|
822
|
+
<slot :name="`cell-${col.key}`" :row="row">
|
|
823
|
+
{{ row[col.key] }}
|
|
824
|
+
</slot>
|
|
825
|
+
</template>
|
|
826
|
+
</div>
|
|
827
|
+
</TableCell>
|
|
828
|
+
</template>
|
|
724
829
|
</TableRow>
|
|
725
830
|
</template>
|
|
726
|
-
<TableRow v-else>
|
|
831
|
+
<TableRow v-else :style="{ height: densityConfig.cellHeight }">
|
|
727
832
|
<TableCell :colspan="columns.length" class="h-24 text-center">
|
|
728
833
|
No results.
|
|
729
834
|
</TableCell>
|
|
@@ -4,18 +4,29 @@ import { cn } from '@/lib/utils'
|
|
|
4
4
|
|
|
5
5
|
const props = defineProps<{
|
|
6
6
|
class?: HTMLAttributes['class']
|
|
7
|
+
style?: any
|
|
8
|
+
padding?: string
|
|
9
|
+
height?: string
|
|
7
10
|
}>()
|
|
8
11
|
|
|
9
12
|
const delegatedProps = computed(() => {
|
|
10
|
-
const { class: _, ...delegated } = props
|
|
13
|
+
const { class: _, padding: __, height: ___, style: ____, ...delegated } = props
|
|
11
14
|
|
|
12
15
|
return delegated
|
|
13
16
|
})
|
|
17
|
+
|
|
18
|
+
const cellStyle = computed(() => {
|
|
19
|
+
const baseStyle = (props as any).style || {}
|
|
20
|
+
const heightStyle = props.height ? { minHeight: props.height } : {}
|
|
21
|
+
|
|
22
|
+
return { ...baseStyle, ...heightStyle }
|
|
23
|
+
})
|
|
14
24
|
</script>
|
|
15
25
|
|
|
16
26
|
<template>
|
|
17
27
|
<td
|
|
18
|
-
:class="cn('p-
|
|
28
|
+
:class="cn(props.padding || 'p-2', 'align-middle [&:has([role=checkbox])]:pr-0', props.class)"
|
|
29
|
+
:style="cellStyle"
|
|
19
30
|
v-bind="delegatedProps"
|
|
20
31
|
>
|
|
21
32
|
<slot />
|
|
@@ -4,10 +4,12 @@ import { cn } from '@/lib/utils'
|
|
|
4
4
|
|
|
5
5
|
const props = defineProps<{
|
|
6
6
|
class?: HTMLAttributes['class']
|
|
7
|
+
height?: string
|
|
8
|
+
padding?: string
|
|
7
9
|
}>()
|
|
8
10
|
|
|
9
11
|
const delegatedProps = computed(() => {
|
|
10
|
-
const { class: _, ...delegated } = props
|
|
12
|
+
const { class: _, height: __, padding: ___, ...delegated } = props
|
|
11
13
|
|
|
12
14
|
return delegated
|
|
13
15
|
})
|
|
@@ -17,7 +19,9 @@ const delegatedProps = computed(() => {
|
|
|
17
19
|
<th
|
|
18
20
|
:class="
|
|
19
21
|
cn(
|
|
20
|
-
'h-
|
|
22
|
+
props.height || 'h-[38px]',
|
|
23
|
+
props.padding || 'px-2',
|
|
24
|
+
'text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
|
21
25
|
props.class,
|
|
22
26
|
)
|
|
23
27
|
"
|