cynx-lib 1.3.84 → 1.3.85
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/gen.js +77 -77
- package/package.json +5 -1
- package/ui/components/Drawer.jsx +208 -208
- package/ui/components/Pagination.jsx +145 -145
- package/ui/components/PrivacyBlur.jsx +603 -603
- package/utils/index.cjs +5 -0
- package/utils/index.js +22 -20
- package/utils/utils/duration.js +139 -139
- package/utils/utils/oidc.js +214 -214
- package/utils/utils/realtime.js +1634 -1634
- package/utils/utils/session.cjs +15 -2
- package/utils/utils/session.js +67 -58
- package/utils/utils/session_manager.cjs +490 -0
- package/utils/utils/session_manager.js +549 -0
- package/utils/utils/storage.js +393 -393
- package/utils/utils/ui.js +111 -111
- package/vue/components/Badge.vue +79 -79
- package/vue/components/Button.vue +209 -209
- package/vue/components/Checkbox.vue +103 -103
- package/vue/components/CircularProgress.vue +383 -383
- package/vue/components/Divider.vue +94 -94
- package/vue/components/Drawer.vue +243 -243
- package/vue/components/InfoBadge.vue +223 -223
- package/vue/components/Input.vue +341 -341
- package/vue/components/LinearProgress.vue +268 -268
- package/vue/components/Modal.vue +226 -226
- package/vue/components/Pagination.vue +199 -199
- package/vue/components/Select.vue +713 -713
- package/vue/components/Sidebar.vue +955 -955
- package/vue/components/Skeleton.vue +91 -91
- package/vue/components/Spinner.vue +263 -263
- package/vue/components/StatusTag.vue +89 -89
- package/vue/components/TabBar.vue +176 -176
- package/vue/components/Table.vue +388 -388
- package/vue/components/TimePicker.vue +399 -399
- package/vue/components/Toggle.vue +115 -115
- package/vue/components/XTable.vue +119 -119
- package/vue/composables/useAutoSkeletonRows.ts +71 -71
- package/vue/index.d.ts +67 -67
- package/vue/index.js +86 -86
package/vue/components/Table.vue
CHANGED
|
@@ -1,388 +1,388 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from "vue";
|
|
3
|
-
import {
|
|
4
|
-
ChevronUp,
|
|
5
|
-
ChevronDown,
|
|
6
|
-
ArrowUpDown,
|
|
7
|
-
FileText,
|
|
8
|
-
} from "lucide-vue-next";
|
|
9
|
-
import Skeleton from "./Skeleton.vue";
|
|
10
|
-
import { useAutoSkeletonRows } from "../composables/useAutoSkeletonRows";
|
|
11
|
-
|
|
12
|
-
export interface TableColumn {
|
|
13
|
-
key: string;
|
|
14
|
-
title?: string;
|
|
15
|
-
label?: string;
|
|
16
|
-
width?: string | number;
|
|
17
|
-
minWidth?: string | number;
|
|
18
|
-
align?: "left" | "center" | "right";
|
|
19
|
-
sortable?: boolean;
|
|
20
|
-
fixed?: "left" | "right" | boolean;
|
|
21
|
-
headerClass?: string;
|
|
22
|
-
cellClass?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const props = withDefaults(
|
|
26
|
-
defineProps<{
|
|
27
|
-
columns?: TableColumn[];
|
|
28
|
-
data?: any[];
|
|
29
|
-
loading?: boolean;
|
|
30
|
-
rowKey?: string | ((row: any) => string | number);
|
|
31
|
-
sortBy?: string;
|
|
32
|
-
sortOrder?: "asc" | "desc" | string;
|
|
33
|
-
emptyText?: string;
|
|
34
|
-
height?: string | number;
|
|
35
|
-
hoverable?: boolean;
|
|
36
|
-
striped?: boolean;
|
|
37
|
-
bordered?: boolean;
|
|
38
|
-
skeletonRows?: number;
|
|
39
|
-
autoSkeleton?: boolean;
|
|
40
|
-
rowHeight?: number;
|
|
41
|
-
}>(),
|
|
42
|
-
{
|
|
43
|
-
columns: () => [],
|
|
44
|
-
data: () => [],
|
|
45
|
-
loading: false,
|
|
46
|
-
rowKey: "id",
|
|
47
|
-
sortBy: "",
|
|
48
|
-
sortOrder: "",
|
|
49
|
-
emptyText: "No data available",
|
|
50
|
-
hoverable: true,
|
|
51
|
-
striped: false,
|
|
52
|
-
bordered: true,
|
|
53
|
-
skeletonRows: undefined,
|
|
54
|
-
autoSkeleton: true,
|
|
55
|
-
rowHeight: 40,
|
|
56
|
-
}
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
const emit = defineEmits<{
|
|
60
|
-
(e: "row-click", row: any, event: MouseEvent): void;
|
|
61
|
-
(e: "sort-change", payload: { key: string; order: "asc" | "desc" | null }): void;
|
|
62
|
-
}>();
|
|
63
|
-
|
|
64
|
-
const rootRef = ref<HTMLElement | null>(null);
|
|
65
|
-
const headerScrollRef = ref<HTMLElement | null>(null);
|
|
66
|
-
const bodyScrollRef = ref<HTMLElement | null>(null);
|
|
67
|
-
const gutterWidth = ref(0);
|
|
68
|
-
|
|
69
|
-
const updateGutter = () => {
|
|
70
|
-
if (bodyScrollRef.value) {
|
|
71
|
-
const hasVScroll = bodyScrollRef.value.scrollHeight > bodyScrollRef.value.clientHeight;
|
|
72
|
-
if (hasVScroll) {
|
|
73
|
-
const scrollWidth = bodyScrollRef.value.offsetWidth - bodyScrollRef.value.clientWidth;
|
|
74
|
-
gutterWidth.value = scrollWidth > 0 ? scrollWidth : 6;
|
|
75
|
-
} else {
|
|
76
|
-
gutterWidth.value = 0;
|
|
77
|
-
}
|
|
78
|
-
} else {
|
|
79
|
-
gutterWidth.value = 0;
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
let resizeObserver: ResizeObserver | null = null;
|
|
84
|
-
|
|
85
|
-
onMounted(() => {
|
|
86
|
-
if (bodyScrollRef.value && typeof ResizeObserver !== "undefined") {
|
|
87
|
-
resizeObserver = new ResizeObserver(() => {
|
|
88
|
-
updateGutter();
|
|
89
|
-
});
|
|
90
|
-
resizeObserver.observe(bodyScrollRef.value);
|
|
91
|
-
}
|
|
92
|
-
nextTick(updateGutter);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
onUnmounted(() => {
|
|
96
|
-
resizeObserver?.disconnect();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
watch(
|
|
100
|
-
() => [props.data, props.columns, props.loading],
|
|
101
|
-
() => {
|
|
102
|
-
nextTick(updateGutter);
|
|
103
|
-
},
|
|
104
|
-
{ deep: true }
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const handleBodyScroll = () => {
|
|
108
|
-
if (headerScrollRef.value && bodyScrollRef.value) {
|
|
109
|
-
headerScrollRef.value.scrollLeft = bodyScrollRef.value.scrollLeft;
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const { rows: autoRows } = useAutoSkeletonRows(rootRef, {
|
|
114
|
-
rowHeight: props.rowHeight,
|
|
115
|
-
bottomBuffer: 50,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const skeletonCount = computed(() => {
|
|
119
|
-
if (props.skeletonRows && props.skeletonRows > 0) return props.skeletonRows;
|
|
120
|
-
if (props.autoSkeleton) return autoRows.value;
|
|
121
|
-
return 10;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const getRowKey = (row: any, index: number) => {
|
|
125
|
-
if (typeof props.rowKey === "function") return props.rowKey(row);
|
|
126
|
-
if (typeof props.rowKey === "string" && row[props.rowKey] !== undefined) return row[props.rowKey];
|
|
127
|
-
return index;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const handleSort = (col: TableColumn) => {
|
|
131
|
-
if (!col.sortable) return;
|
|
132
|
-
const isCurrent = props.sortBy === col.key;
|
|
133
|
-
let nextOrder: "asc" | "desc" | null = "asc";
|
|
134
|
-
|
|
135
|
-
if (isCurrent) {
|
|
136
|
-
if (props.sortOrder === "asc") nextOrder = "desc";
|
|
137
|
-
else if (props.sortOrder === "desc") nextOrder = null;
|
|
138
|
-
else nextOrder = "asc";
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
emit("sort-change", { key: col.key, order: nextOrder });
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const formatWidth = (val?: string | number) => {
|
|
145
|
-
if (val === undefined || val === null) return undefined;
|
|
146
|
-
if (typeof val === "number") return `${val}px`;
|
|
147
|
-
return val;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const titleWidths = ["68%", "82%", "55%", "75%", "62%", "88%", "70%", "64%"];
|
|
151
|
-
const textWidths = ["80%", "65%", "88%", "72%", "58%", "84%", "62%", "76%"];
|
|
152
|
-
const codeWidths = ["50%", "62%", "45%", "58%", "52%", "60%"];
|
|
153
|
-
|
|
154
|
-
const getSkeletonWidth = (rIdx: number, cIdx: number, key: string) => {
|
|
155
|
-
if (key === "title" || cIdx === 0) {
|
|
156
|
-
return titleWidths[(rIdx + cIdx) % titleWidths.length];
|
|
157
|
-
}
|
|
158
|
-
if (key === "usdot" || key === "phone" || key === "id") {
|
|
159
|
-
return codeWidths[(rIdx + cIdx) % codeWidths.length];
|
|
160
|
-
}
|
|
161
|
-
return textWidths[(rIdx + cIdx) % textWidths.length];
|
|
162
|
-
};
|
|
163
|
-
</script>
|
|
164
|
-
|
|
165
|
-
<template>
|
|
166
|
-
<div
|
|
167
|
-
ref="rootRef"
|
|
168
|
-
class="w-full flex flex-col overflow-hidden transition-colors"
|
|
169
|
-
:class="[
|
|
170
|
-
bordered ? 'border border-slate-200 dark:border-slate-800' : '',
|
|
171
|
-
'bg-white dark:bg-[#141629]'
|
|
172
|
-
]"
|
|
173
|
-
:style="height ? { height: formatWidth(height) } : undefined"
|
|
174
|
-
>
|
|
175
|
-
<!-- Dedicated Fixed Header Container (Never moves on row scroll) -->
|
|
176
|
-
<div
|
|
177
|
-
ref="headerScrollRef"
|
|
178
|
-
class="w-full overflow-hidden flex-shrink-0 border-b border-slate-200 dark:border-slate-800 bg-[#f8fafc] dark:bg-[#16192e]"
|
|
179
|
-
>
|
|
180
|
-
<table class="w-full border-collapse text-left text-xs table-fixed">
|
|
181
|
-
<colgroup>
|
|
182
|
-
<col
|
|
183
|
-
v-for="col in columns"
|
|
184
|
-
:key="`h-col-${col.key}`"
|
|
185
|
-
:style="{
|
|
186
|
-
width: formatWidth(col.width),
|
|
187
|
-
minWidth: formatWidth(col.minWidth || col.width || 100),
|
|
188
|
-
}"
|
|
189
|
-
/>
|
|
190
|
-
<col v-if="gutterWidth > 0" :style="{ width: `${gutterWidth}px` }" />
|
|
191
|
-
</colgroup>
|
|
192
|
-
<thead>
|
|
193
|
-
<tr>
|
|
194
|
-
<th
|
|
195
|
-
v-for="col in columns"
|
|
196
|
-
:key="col.key"
|
|
197
|
-
:style="{
|
|
198
|
-
width: formatWidth(col.width),
|
|
199
|
-
minWidth: formatWidth(col.minWidth || col.width || 100),
|
|
200
|
-
textAlign: col.align || 'left',
|
|
201
|
-
}"
|
|
202
|
-
:class="[
|
|
203
|
-
'px-3.5 py-2.5 font-semibold text-[11px] uppercase tracking-wider text-slate-500 dark:text-slate-400 select-none transition-colors bg-[#f8fafc] dark:bg-[#16192e]',
|
|
204
|
-
col.sortable ? 'cursor-pointer hover:text-slate-900 dark:hover:text-white' : '',
|
|
205
|
-
col.headerClass || ''
|
|
206
|
-
]"
|
|
207
|
-
@click="handleSort(col)"
|
|
208
|
-
>
|
|
209
|
-
<div
|
|
210
|
-
class="flex items-center gap-1.5"
|
|
211
|
-
:class="{
|
|
212
|
-
'justify-start': !col.align || col.align === 'left',
|
|
213
|
-
'justify-center': col.align === 'center',
|
|
214
|
-
'justify-end': col.align === 'right',
|
|
215
|
-
}"
|
|
216
|
-
>
|
|
217
|
-
<slot :name="`header-${col.key}`" :column="col">
|
|
218
|
-
<span>{{ col.title || col.label || col.key }}</span>
|
|
219
|
-
</slot>
|
|
220
|
-
|
|
221
|
-
<!-- Sort indicator icon -->
|
|
222
|
-
<span v-if="col.sortable" class="inline-flex items-center text-slate-400">
|
|
223
|
-
<ChevronUp
|
|
224
|
-
v-if="sortBy === col.key && sortOrder === 'asc'"
|
|
225
|
-
:size="13"
|
|
226
|
-
class="text-[#12675D] dark:text-teal-400"
|
|
227
|
-
/>
|
|
228
|
-
<ChevronDown
|
|
229
|
-
v-else-if="sortBy === col.key && sortOrder === 'desc'"
|
|
230
|
-
:size="13"
|
|
231
|
-
class="text-[#12675D] dark:text-teal-400"
|
|
232
|
-
/>
|
|
233
|
-
<ArrowUpDown
|
|
234
|
-
v-else
|
|
235
|
-
:size="11"
|
|
236
|
-
class="opacity-40 hover:opacity-100 transition-opacity"
|
|
237
|
-
/>
|
|
238
|
-
</span>
|
|
239
|
-
</div>
|
|
240
|
-
</th>
|
|
241
|
-
<th
|
|
242
|
-
v-if="gutterWidth > 0"
|
|
243
|
-
class="bg-[#f8fafc] dark:bg-[#16192e] p-0 border-0 pointer-events-none"
|
|
244
|
-
:style="{ width: `${gutterWidth}px`, minWidth: `${gutterWidth}px` }"
|
|
245
|
-
/>
|
|
246
|
-
</tr>
|
|
247
|
-
</thead>
|
|
248
|
-
</table>
|
|
249
|
-
</div>
|
|
250
|
-
|
|
251
|
-
<!-- Dedicated Scrollable Body Container -->
|
|
252
|
-
<div
|
|
253
|
-
ref="bodyScrollRef"
|
|
254
|
-
class="w-full overflow-x-auto overflow-y-auto custom-scrollbar flex-1 relative"
|
|
255
|
-
@scroll="handleBodyScroll"
|
|
256
|
-
>
|
|
257
|
-
<table class="w-full border-collapse text-left text-xs table-fixed">
|
|
258
|
-
<colgroup>
|
|
259
|
-
<col
|
|
260
|
-
v-for="col in columns"
|
|
261
|
-
:key="`b-col-${col.key}`"
|
|
262
|
-
:style="{
|
|
263
|
-
width: formatWidth(col.width),
|
|
264
|
-
minWidth: formatWidth(col.minWidth || col.width || 100),
|
|
265
|
-
}"
|
|
266
|
-
/>
|
|
267
|
-
</colgroup>
|
|
268
|
-
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
|
|
269
|
-
<!-- Loading State (Real matching heights and paddings) -->
|
|
270
|
-
<template v-if="loading">
|
|
271
|
-
<tr
|
|
272
|
-
v-for="i in skeletonCount"
|
|
273
|
-
:key="`sk-${i}`"
|
|
274
|
-
:style="{ height: `${rowHeight}px` }"
|
|
275
|
-
>
|
|
276
|
-
<td
|
|
277
|
-
v-for="(col, cIdx) in columns"
|
|
278
|
-
:key="`sk-c-${col.key}`"
|
|
279
|
-
:style="{
|
|
280
|
-
textAlign: col.align || 'left',
|
|
281
|
-
height: `${rowHeight}px`,
|
|
282
|
-
}"
|
|
283
|
-
class="px-3.5 py-2 align-middle"
|
|
284
|
-
>
|
|
285
|
-
<!-- Action button skeleton -->
|
|
286
|
-
<div v-if="col.key === 'actions'" class="flex items-center justify-center">
|
|
287
|
-
<Skeleton :width="28" :height="28" :radius="6" />
|
|
288
|
-
</div>
|
|
289
|
-
|
|
290
|
-
<!-- Centered badge skeleton -->
|
|
291
|
-
<div v-else-if="col.align === 'center'" class="flex items-center justify-center">
|
|
292
|
-
<Skeleton :width="44" :height="20" :radius="10" />
|
|
293
|
-
</div>
|
|
294
|
-
|
|
295
|
-
<!-- Text / Column skeleton -->
|
|
296
|
-
<Skeleton
|
|
297
|
-
v-else
|
|
298
|
-
:width="getSkeletonWidth(i, cIdx, col.key)"
|
|
299
|
-
:height="14"
|
|
300
|
-
:radius="4"
|
|
301
|
-
/>
|
|
302
|
-
</td>
|
|
303
|
-
</tr>
|
|
304
|
-
</template>
|
|
305
|
-
|
|
306
|
-
<!-- Empty State -->
|
|
307
|
-
<tr v-else-if="!data || data.length === 0">
|
|
308
|
-
<td :colspan="columns.length || 1" class="py-16 text-center text-slate-400 dark:text-slate-500">
|
|
309
|
-
<slot name="empty">
|
|
310
|
-
<div class="flex flex-col items-center justify-center gap-2">
|
|
311
|
-
<FileText :size="32" class="opacity-30" />
|
|
312
|
-
<span class="text-xs">{{ emptyText }}</span>
|
|
313
|
-
</div>
|
|
314
|
-
</slot>
|
|
315
|
-
</td>
|
|
316
|
-
</tr>
|
|
317
|
-
|
|
318
|
-
<!-- Data Rows -->
|
|
319
|
-
<template v-else>
|
|
320
|
-
<tr
|
|
321
|
-
v-for="(row, rIdx) in data"
|
|
322
|
-
:key="getRowKey(row, rIdx)"
|
|
323
|
-
:style="{ height: `${rowHeight}px` }"
|
|
324
|
-
:class="[
|
|
325
|
-
'transition-colors duration-100',
|
|
326
|
-
hoverable ? 'hover:bg-slate-50/80 dark:hover:bg-slate-800/50 cursor-pointer' : '',
|
|
327
|
-
striped && rIdx % 2 === 1 ? 'bg-slate-50/40 dark:bg-slate-900/30' : '',
|
|
328
|
-
]"
|
|
329
|
-
@click="emit('row-click', row, $event)"
|
|
330
|
-
>
|
|
331
|
-
<td
|
|
332
|
-
v-for="col in columns"
|
|
333
|
-
:key="col.key"
|
|
334
|
-
:style="{
|
|
335
|
-
textAlign: col.align || 'left',
|
|
336
|
-
height: `${rowHeight}px`,
|
|
337
|
-
}"
|
|
338
|
-
:class="[
|
|
339
|
-
'px-3.5 py-2 text-slate-700 dark:text-slate-200 text-xs font-normal align-middle',
|
|
340
|
-
col.cellClass || ''
|
|
341
|
-
]"
|
|
342
|
-
>
|
|
343
|
-
<!-- Dedicated Column Cell Slot (e.g. #cell-title, #cell-actions) or default slot -->
|
|
344
|
-
<slot
|
|
345
|
-
:name="`cell-${col.key}`"
|
|
346
|
-
:row="row"
|
|
347
|
-
:column="col"
|
|
348
|
-
:index="rIdx"
|
|
349
|
-
:value="row[col.key]"
|
|
350
|
-
>
|
|
351
|
-
<slot
|
|
352
|
-
:name="col.key"
|
|
353
|
-
:row="row"
|
|
354
|
-
:column="col"
|
|
355
|
-
:index="rIdx"
|
|
356
|
-
:value="row[col.key]"
|
|
357
|
-
>
|
|
358
|
-
<span>{{ row[col.key] != null && row[col.key] !== '' ? row[col.key] : '-' }}</span>
|
|
359
|
-
</slot>
|
|
360
|
-
</slot>
|
|
361
|
-
</td>
|
|
362
|
-
</tr>
|
|
363
|
-
</template>
|
|
364
|
-
</tbody>
|
|
365
|
-
</table>
|
|
366
|
-
</div>
|
|
367
|
-
</div>
|
|
368
|
-
</template>
|
|
369
|
-
|
|
370
|
-
<style scoped>
|
|
371
|
-
.custom-scrollbar {
|
|
372
|
-
scrollbar-width: thin;
|
|
373
|
-
}
|
|
374
|
-
.custom-scrollbar::-webkit-scrollbar {
|
|
375
|
-
width: 6px;
|
|
376
|
-
height: 6px;
|
|
377
|
-
}
|
|
378
|
-
.custom-scrollbar::-webkit-scrollbar-track {
|
|
379
|
-
background: transparent;
|
|
380
|
-
}
|
|
381
|
-
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
382
|
-
background-color: rgba(148, 163, 184, 0.3);
|
|
383
|
-
border-radius: 6px;
|
|
384
|
-
}
|
|
385
|
-
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
386
|
-
background-color: rgba(148, 163, 184, 0.5);
|
|
387
|
-
}
|
|
388
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
ChevronUp,
|
|
5
|
+
ChevronDown,
|
|
6
|
+
ArrowUpDown,
|
|
7
|
+
FileText,
|
|
8
|
+
} from "lucide-vue-next";
|
|
9
|
+
import Skeleton from "./Skeleton.vue";
|
|
10
|
+
import { useAutoSkeletonRows } from "../composables/useAutoSkeletonRows";
|
|
11
|
+
|
|
12
|
+
export interface TableColumn {
|
|
13
|
+
key: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
width?: string | number;
|
|
17
|
+
minWidth?: string | number;
|
|
18
|
+
align?: "left" | "center" | "right";
|
|
19
|
+
sortable?: boolean;
|
|
20
|
+
fixed?: "left" | "right" | boolean;
|
|
21
|
+
headerClass?: string;
|
|
22
|
+
cellClass?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(
|
|
26
|
+
defineProps<{
|
|
27
|
+
columns?: TableColumn[];
|
|
28
|
+
data?: any[];
|
|
29
|
+
loading?: boolean;
|
|
30
|
+
rowKey?: string | ((row: any) => string | number);
|
|
31
|
+
sortBy?: string;
|
|
32
|
+
sortOrder?: "asc" | "desc" | string;
|
|
33
|
+
emptyText?: string;
|
|
34
|
+
height?: string | number;
|
|
35
|
+
hoverable?: boolean;
|
|
36
|
+
striped?: boolean;
|
|
37
|
+
bordered?: boolean;
|
|
38
|
+
skeletonRows?: number;
|
|
39
|
+
autoSkeleton?: boolean;
|
|
40
|
+
rowHeight?: number;
|
|
41
|
+
}>(),
|
|
42
|
+
{
|
|
43
|
+
columns: () => [],
|
|
44
|
+
data: () => [],
|
|
45
|
+
loading: false,
|
|
46
|
+
rowKey: "id",
|
|
47
|
+
sortBy: "",
|
|
48
|
+
sortOrder: "",
|
|
49
|
+
emptyText: "No data available",
|
|
50
|
+
hoverable: true,
|
|
51
|
+
striped: false,
|
|
52
|
+
bordered: true,
|
|
53
|
+
skeletonRows: undefined,
|
|
54
|
+
autoSkeleton: true,
|
|
55
|
+
rowHeight: 40,
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const emit = defineEmits<{
|
|
60
|
+
(e: "row-click", row: any, event: MouseEvent): void;
|
|
61
|
+
(e: "sort-change", payload: { key: string; order: "asc" | "desc" | null }): void;
|
|
62
|
+
}>();
|
|
63
|
+
|
|
64
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
65
|
+
const headerScrollRef = ref<HTMLElement | null>(null);
|
|
66
|
+
const bodyScrollRef = ref<HTMLElement | null>(null);
|
|
67
|
+
const gutterWidth = ref(0);
|
|
68
|
+
|
|
69
|
+
const updateGutter = () => {
|
|
70
|
+
if (bodyScrollRef.value) {
|
|
71
|
+
const hasVScroll = bodyScrollRef.value.scrollHeight > bodyScrollRef.value.clientHeight;
|
|
72
|
+
if (hasVScroll) {
|
|
73
|
+
const scrollWidth = bodyScrollRef.value.offsetWidth - bodyScrollRef.value.clientWidth;
|
|
74
|
+
gutterWidth.value = scrollWidth > 0 ? scrollWidth : 6;
|
|
75
|
+
} else {
|
|
76
|
+
gutterWidth.value = 0;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
gutterWidth.value = 0;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
84
|
+
|
|
85
|
+
onMounted(() => {
|
|
86
|
+
if (bodyScrollRef.value && typeof ResizeObserver !== "undefined") {
|
|
87
|
+
resizeObserver = new ResizeObserver(() => {
|
|
88
|
+
updateGutter();
|
|
89
|
+
});
|
|
90
|
+
resizeObserver.observe(bodyScrollRef.value);
|
|
91
|
+
}
|
|
92
|
+
nextTick(updateGutter);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
onUnmounted(() => {
|
|
96
|
+
resizeObserver?.disconnect();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
watch(
|
|
100
|
+
() => [props.data, props.columns, props.loading],
|
|
101
|
+
() => {
|
|
102
|
+
nextTick(updateGutter);
|
|
103
|
+
},
|
|
104
|
+
{ deep: true }
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleBodyScroll = () => {
|
|
108
|
+
if (headerScrollRef.value && bodyScrollRef.value) {
|
|
109
|
+
headerScrollRef.value.scrollLeft = bodyScrollRef.value.scrollLeft;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const { rows: autoRows } = useAutoSkeletonRows(rootRef, {
|
|
114
|
+
rowHeight: props.rowHeight,
|
|
115
|
+
bottomBuffer: 50,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const skeletonCount = computed(() => {
|
|
119
|
+
if (props.skeletonRows && props.skeletonRows > 0) return props.skeletonRows;
|
|
120
|
+
if (props.autoSkeleton) return autoRows.value;
|
|
121
|
+
return 10;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const getRowKey = (row: any, index: number) => {
|
|
125
|
+
if (typeof props.rowKey === "function") return props.rowKey(row);
|
|
126
|
+
if (typeof props.rowKey === "string" && row[props.rowKey] !== undefined) return row[props.rowKey];
|
|
127
|
+
return index;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSort = (col: TableColumn) => {
|
|
131
|
+
if (!col.sortable) return;
|
|
132
|
+
const isCurrent = props.sortBy === col.key;
|
|
133
|
+
let nextOrder: "asc" | "desc" | null = "asc";
|
|
134
|
+
|
|
135
|
+
if (isCurrent) {
|
|
136
|
+
if (props.sortOrder === "asc") nextOrder = "desc";
|
|
137
|
+
else if (props.sortOrder === "desc") nextOrder = null;
|
|
138
|
+
else nextOrder = "asc";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
emit("sort-change", { key: col.key, order: nextOrder });
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const formatWidth = (val?: string | number) => {
|
|
145
|
+
if (val === undefined || val === null) return undefined;
|
|
146
|
+
if (typeof val === "number") return `${val}px`;
|
|
147
|
+
return val;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const titleWidths = ["68%", "82%", "55%", "75%", "62%", "88%", "70%", "64%"];
|
|
151
|
+
const textWidths = ["80%", "65%", "88%", "72%", "58%", "84%", "62%", "76%"];
|
|
152
|
+
const codeWidths = ["50%", "62%", "45%", "58%", "52%", "60%"];
|
|
153
|
+
|
|
154
|
+
const getSkeletonWidth = (rIdx: number, cIdx: number, key: string) => {
|
|
155
|
+
if (key === "title" || cIdx === 0) {
|
|
156
|
+
return titleWidths[(rIdx + cIdx) % titleWidths.length];
|
|
157
|
+
}
|
|
158
|
+
if (key === "usdot" || key === "phone" || key === "id") {
|
|
159
|
+
return codeWidths[(rIdx + cIdx) % codeWidths.length];
|
|
160
|
+
}
|
|
161
|
+
return textWidths[(rIdx + cIdx) % textWidths.length];
|
|
162
|
+
};
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<template>
|
|
166
|
+
<div
|
|
167
|
+
ref="rootRef"
|
|
168
|
+
class="w-full flex flex-col overflow-hidden transition-colors"
|
|
169
|
+
:class="[
|
|
170
|
+
bordered ? 'border border-slate-200 dark:border-slate-800' : '',
|
|
171
|
+
'bg-white dark:bg-[#141629]'
|
|
172
|
+
]"
|
|
173
|
+
:style="height ? { height: formatWidth(height) } : undefined"
|
|
174
|
+
>
|
|
175
|
+
<!-- Dedicated Fixed Header Container (Never moves on row scroll) -->
|
|
176
|
+
<div
|
|
177
|
+
ref="headerScrollRef"
|
|
178
|
+
class="w-full overflow-hidden flex-shrink-0 border-b border-slate-200 dark:border-slate-800 bg-[#f8fafc] dark:bg-[#16192e]"
|
|
179
|
+
>
|
|
180
|
+
<table class="w-full border-collapse text-left text-xs table-fixed">
|
|
181
|
+
<colgroup>
|
|
182
|
+
<col
|
|
183
|
+
v-for="col in columns"
|
|
184
|
+
:key="`h-col-${col.key}`"
|
|
185
|
+
:style="{
|
|
186
|
+
width: formatWidth(col.width),
|
|
187
|
+
minWidth: formatWidth(col.minWidth || col.width || 100),
|
|
188
|
+
}"
|
|
189
|
+
/>
|
|
190
|
+
<col v-if="gutterWidth > 0" :style="{ width: `${gutterWidth}px` }" />
|
|
191
|
+
</colgroup>
|
|
192
|
+
<thead>
|
|
193
|
+
<tr>
|
|
194
|
+
<th
|
|
195
|
+
v-for="col in columns"
|
|
196
|
+
:key="col.key"
|
|
197
|
+
:style="{
|
|
198
|
+
width: formatWidth(col.width),
|
|
199
|
+
minWidth: formatWidth(col.minWidth || col.width || 100),
|
|
200
|
+
textAlign: col.align || 'left',
|
|
201
|
+
}"
|
|
202
|
+
:class="[
|
|
203
|
+
'px-3.5 py-2.5 font-semibold text-[11px] uppercase tracking-wider text-slate-500 dark:text-slate-400 select-none transition-colors bg-[#f8fafc] dark:bg-[#16192e]',
|
|
204
|
+
col.sortable ? 'cursor-pointer hover:text-slate-900 dark:hover:text-white' : '',
|
|
205
|
+
col.headerClass || ''
|
|
206
|
+
]"
|
|
207
|
+
@click="handleSort(col)"
|
|
208
|
+
>
|
|
209
|
+
<div
|
|
210
|
+
class="flex items-center gap-1.5"
|
|
211
|
+
:class="{
|
|
212
|
+
'justify-start': !col.align || col.align === 'left',
|
|
213
|
+
'justify-center': col.align === 'center',
|
|
214
|
+
'justify-end': col.align === 'right',
|
|
215
|
+
}"
|
|
216
|
+
>
|
|
217
|
+
<slot :name="`header-${col.key}`" :column="col">
|
|
218
|
+
<span>{{ col.title || col.label || col.key }}</span>
|
|
219
|
+
</slot>
|
|
220
|
+
|
|
221
|
+
<!-- Sort indicator icon -->
|
|
222
|
+
<span v-if="col.sortable" class="inline-flex items-center text-slate-400">
|
|
223
|
+
<ChevronUp
|
|
224
|
+
v-if="sortBy === col.key && sortOrder === 'asc'"
|
|
225
|
+
:size="13"
|
|
226
|
+
class="text-[#12675D] dark:text-teal-400"
|
|
227
|
+
/>
|
|
228
|
+
<ChevronDown
|
|
229
|
+
v-else-if="sortBy === col.key && sortOrder === 'desc'"
|
|
230
|
+
:size="13"
|
|
231
|
+
class="text-[#12675D] dark:text-teal-400"
|
|
232
|
+
/>
|
|
233
|
+
<ArrowUpDown
|
|
234
|
+
v-else
|
|
235
|
+
:size="11"
|
|
236
|
+
class="opacity-40 hover:opacity-100 transition-opacity"
|
|
237
|
+
/>
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
</th>
|
|
241
|
+
<th
|
|
242
|
+
v-if="gutterWidth > 0"
|
|
243
|
+
class="bg-[#f8fafc] dark:bg-[#16192e] p-0 border-0 pointer-events-none"
|
|
244
|
+
:style="{ width: `${gutterWidth}px`, minWidth: `${gutterWidth}px` }"
|
|
245
|
+
/>
|
|
246
|
+
</tr>
|
|
247
|
+
</thead>
|
|
248
|
+
</table>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<!-- Dedicated Scrollable Body Container -->
|
|
252
|
+
<div
|
|
253
|
+
ref="bodyScrollRef"
|
|
254
|
+
class="w-full overflow-x-auto overflow-y-auto custom-scrollbar flex-1 relative"
|
|
255
|
+
@scroll="handleBodyScroll"
|
|
256
|
+
>
|
|
257
|
+
<table class="w-full border-collapse text-left text-xs table-fixed">
|
|
258
|
+
<colgroup>
|
|
259
|
+
<col
|
|
260
|
+
v-for="col in columns"
|
|
261
|
+
:key="`b-col-${col.key}`"
|
|
262
|
+
:style="{
|
|
263
|
+
width: formatWidth(col.width),
|
|
264
|
+
minWidth: formatWidth(col.minWidth || col.width || 100),
|
|
265
|
+
}"
|
|
266
|
+
/>
|
|
267
|
+
</colgroup>
|
|
268
|
+
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
|
|
269
|
+
<!-- Loading State (Real matching heights and paddings) -->
|
|
270
|
+
<template v-if="loading">
|
|
271
|
+
<tr
|
|
272
|
+
v-for="i in skeletonCount"
|
|
273
|
+
:key="`sk-${i}`"
|
|
274
|
+
:style="{ height: `${rowHeight}px` }"
|
|
275
|
+
>
|
|
276
|
+
<td
|
|
277
|
+
v-for="(col, cIdx) in columns"
|
|
278
|
+
:key="`sk-c-${col.key}`"
|
|
279
|
+
:style="{
|
|
280
|
+
textAlign: col.align || 'left',
|
|
281
|
+
height: `${rowHeight}px`,
|
|
282
|
+
}"
|
|
283
|
+
class="px-3.5 py-2 align-middle"
|
|
284
|
+
>
|
|
285
|
+
<!-- Action button skeleton -->
|
|
286
|
+
<div v-if="col.key === 'actions'" class="flex items-center justify-center">
|
|
287
|
+
<Skeleton :width="28" :height="28" :radius="6" />
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<!-- Centered badge skeleton -->
|
|
291
|
+
<div v-else-if="col.align === 'center'" class="flex items-center justify-center">
|
|
292
|
+
<Skeleton :width="44" :height="20" :radius="10" />
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<!-- Text / Column skeleton -->
|
|
296
|
+
<Skeleton
|
|
297
|
+
v-else
|
|
298
|
+
:width="getSkeletonWidth(i, cIdx, col.key)"
|
|
299
|
+
:height="14"
|
|
300
|
+
:radius="4"
|
|
301
|
+
/>
|
|
302
|
+
</td>
|
|
303
|
+
</tr>
|
|
304
|
+
</template>
|
|
305
|
+
|
|
306
|
+
<!-- Empty State -->
|
|
307
|
+
<tr v-else-if="!data || data.length === 0">
|
|
308
|
+
<td :colspan="columns.length || 1" class="py-16 text-center text-slate-400 dark:text-slate-500">
|
|
309
|
+
<slot name="empty">
|
|
310
|
+
<div class="flex flex-col items-center justify-center gap-2">
|
|
311
|
+
<FileText :size="32" class="opacity-30" />
|
|
312
|
+
<span class="text-xs">{{ emptyText }}</span>
|
|
313
|
+
</div>
|
|
314
|
+
</slot>
|
|
315
|
+
</td>
|
|
316
|
+
</tr>
|
|
317
|
+
|
|
318
|
+
<!-- Data Rows -->
|
|
319
|
+
<template v-else>
|
|
320
|
+
<tr
|
|
321
|
+
v-for="(row, rIdx) in data"
|
|
322
|
+
:key="getRowKey(row, rIdx)"
|
|
323
|
+
:style="{ height: `${rowHeight}px` }"
|
|
324
|
+
:class="[
|
|
325
|
+
'transition-colors duration-100',
|
|
326
|
+
hoverable ? 'hover:bg-slate-50/80 dark:hover:bg-slate-800/50 cursor-pointer' : '',
|
|
327
|
+
striped && rIdx % 2 === 1 ? 'bg-slate-50/40 dark:bg-slate-900/30' : '',
|
|
328
|
+
]"
|
|
329
|
+
@click="emit('row-click', row, $event)"
|
|
330
|
+
>
|
|
331
|
+
<td
|
|
332
|
+
v-for="col in columns"
|
|
333
|
+
:key="col.key"
|
|
334
|
+
:style="{
|
|
335
|
+
textAlign: col.align || 'left',
|
|
336
|
+
height: `${rowHeight}px`,
|
|
337
|
+
}"
|
|
338
|
+
:class="[
|
|
339
|
+
'px-3.5 py-2 text-slate-700 dark:text-slate-200 text-xs font-normal align-middle',
|
|
340
|
+
col.cellClass || ''
|
|
341
|
+
]"
|
|
342
|
+
>
|
|
343
|
+
<!-- Dedicated Column Cell Slot (e.g. #cell-title, #cell-actions) or default slot -->
|
|
344
|
+
<slot
|
|
345
|
+
:name="`cell-${col.key}`"
|
|
346
|
+
:row="row"
|
|
347
|
+
:column="col"
|
|
348
|
+
:index="rIdx"
|
|
349
|
+
:value="row[col.key]"
|
|
350
|
+
>
|
|
351
|
+
<slot
|
|
352
|
+
:name="col.key"
|
|
353
|
+
:row="row"
|
|
354
|
+
:column="col"
|
|
355
|
+
:index="rIdx"
|
|
356
|
+
:value="row[col.key]"
|
|
357
|
+
>
|
|
358
|
+
<span>{{ row[col.key] != null && row[col.key] !== '' ? row[col.key] : '-' }}</span>
|
|
359
|
+
</slot>
|
|
360
|
+
</slot>
|
|
361
|
+
</td>
|
|
362
|
+
</tr>
|
|
363
|
+
</template>
|
|
364
|
+
</tbody>
|
|
365
|
+
</table>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</template>
|
|
369
|
+
|
|
370
|
+
<style scoped>
|
|
371
|
+
.custom-scrollbar {
|
|
372
|
+
scrollbar-width: thin;
|
|
373
|
+
}
|
|
374
|
+
.custom-scrollbar::-webkit-scrollbar {
|
|
375
|
+
width: 6px;
|
|
376
|
+
height: 6px;
|
|
377
|
+
}
|
|
378
|
+
.custom-scrollbar::-webkit-scrollbar-track {
|
|
379
|
+
background: transparent;
|
|
380
|
+
}
|
|
381
|
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
382
|
+
background-color: rgba(148, 163, 184, 0.3);
|
|
383
|
+
border-radius: 6px;
|
|
384
|
+
}
|
|
385
|
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
386
|
+
background-color: rgba(148, 163, 184, 0.5);
|
|
387
|
+
}
|
|
388
|
+
</style>
|