@htlkg/components 0.0.2 → 0.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.
@@ -0,0 +1,553 @@
1
+ <script setup lang="ts" generic="T extends Record<string, any>">
2
+ /**
3
+ * DataTable Component
4
+ *
5
+ * A simplified table component that encapsulates useTable composable and Table component,
6
+ * providing a cleaner API with automatic event wiring and column transformations.
7
+ */
8
+
9
+ import { computed, toRef, watch } from "vue";
10
+ import Table from "./Table.vue";
11
+ import { useTable, type SmartFilter } from "../composables/useTable";
12
+
13
+ /**
14
+ * Column definition for DataTable
15
+ */
16
+ export interface DataTableColumn<TItem = any> {
17
+ /** Property key to access from item */
18
+ key: string;
19
+ /** Column header label */
20
+ label: string;
21
+ /** Whether column is sortable (default: true) */
22
+ sortable?: boolean;
23
+ /** Column width */
24
+ width?: string;
25
+ /** Custom render function - returns value for table cell */
26
+ render?: (item: TItem, index: number) => any;
27
+ /** Cell type hint for default formatting */
28
+ type?: "text" | "tag" | "badge" | "date" | "number" | "actions";
29
+ }
30
+
31
+ /**
32
+ * Filter definition for DataTable
33
+ */
34
+ export interface DataTableFilter {
35
+ /** Filter key (matches column key) */
36
+ key: string;
37
+ /** Filter label */
38
+ label: string;
39
+ /** Filter input type */
40
+ type: "text" | "select" | "number" | "date" | "dateRange";
41
+ /** Options for select type */
42
+ options?: Array<{ label: string; value: any }>;
43
+ /** Placeholder text */
44
+ placeholder?: string;
45
+ }
46
+
47
+ /**
48
+ * Bulk action definition
49
+ */
50
+ export interface BulkAction {
51
+ /** Action identifier */
52
+ id: string;
53
+ /** Display label */
54
+ label: string;
55
+ /** Icon name */
56
+ icon?: string;
57
+ /** Visual variant */
58
+ variant?: "default" | "danger" | "warning";
59
+ /** Require confirmation */
60
+ confirm?: boolean;
61
+ }
62
+
63
+ /**
64
+ * Row action definition
65
+ */
66
+ export interface RowAction {
67
+ /** Action identifier */
68
+ id: string;
69
+ /** Display label */
70
+ label: string;
71
+ /** Icon name */
72
+ icon?: string;
73
+ }
74
+
75
+ /**
76
+ * No results configuration
77
+ */
78
+ export interface NoResultsConfig {
79
+ /** Title text */
80
+ title: string;
81
+ /** Message text */
82
+ message: string;
83
+ /** Action buttons */
84
+ actions?: Array<{ action: string; text: string }>;
85
+ }
86
+
87
+ interface Props {
88
+ /** Data items to display */
89
+ items: T[];
90
+ /** Column definitions */
91
+ columns: DataTableColumn<T>[];
92
+ /** Filter definitions */
93
+ filters?: DataTableFilter[];
94
+ /** Initial filter values (from URL params) */
95
+ initialFilters?: Record<string, any>;
96
+ /** Loading state */
97
+ loading?: boolean;
98
+ /** Items per page (default: 10) */
99
+ pageSize?: number;
100
+ /** Current page (for syncUrl mode) */
101
+ currentPage?: number;
102
+ /** Total pages (for syncUrl mode) */
103
+ totalPages?: number;
104
+ /** Total items count (for syncUrl mode) */
105
+ totalItems?: number;
106
+ /** Property key for item ID (default: 'id') */
107
+ idKey?: keyof T;
108
+ /** Row actions (shown in actions column) */
109
+ actions?: RowAction[] | string[];
110
+ /** Enable row selection */
111
+ selectable?: boolean;
112
+ /** Bulk actions (shown when items selected) */
113
+ bulkActions?: BulkAction[];
114
+ /** No results configuration */
115
+ noResults?: NoResultsConfig;
116
+ /** Default sort key */
117
+ defaultSortKey?: string;
118
+ /** Default sort order */
119
+ defaultSortOrder?: "asc" | "desc";
120
+ /** Sync table state with URL (for SSR pages) */
121
+ syncUrl?: boolean;
122
+ }
123
+
124
+ const props = withDefaults(defineProps<Props>(), {
125
+ loading: false,
126
+ pageSize: 10,
127
+ currentPage: 1,
128
+ totalPages: 1,
129
+ totalItems: 0,
130
+ idKey: "id" as keyof T,
131
+ selectable: false,
132
+ defaultSortOrder: "asc",
133
+ syncUrl: false,
134
+ });
135
+
136
+ const emit = defineEmits<{
137
+ /** Row action clicked */
138
+ action: [action: string, item: T];
139
+ /** Bulk action clicked */
140
+ bulkAction: [action: string, items: T[]];
141
+ /** Selection changed */
142
+ selection: [items: T[]];
143
+ /** Row clicked */
144
+ rowClick: [item: T];
145
+ /** Custom cell emit (e.g., link clicks with emits config) */
146
+ customEmit: [data: any];
147
+ /** Page changed (for URL sync) */
148
+ pageChange: [page: number];
149
+ /** Sort changed (for URL sync) */
150
+ sortChange: [key: string, order: "asc" | "desc"];
151
+ /** Filters changed (for URL sync) */
152
+ filterChange: [filters: Record<string, any>];
153
+ }>();
154
+
155
+ /**
156
+ * Update URL with new params (for syncUrl mode)
157
+ */
158
+ function updateUrl(params: Record<string, string | undefined>) {
159
+ if (typeof window === "undefined") return;
160
+
161
+ const url = new URL(window.location.href);
162
+ Object.entries(params).forEach(([key, value]) => {
163
+ if (value !== undefined && value !== "") {
164
+ url.searchParams.set(key, value);
165
+ } else {
166
+ url.searchParams.delete(key);
167
+ }
168
+ });
169
+ // Navigate to trigger server-side data fetch
170
+ window.location.href = url.toString();
171
+ }
172
+
173
+ /**
174
+ * Handle page change with optional URL sync
175
+ */
176
+ function handlePageChange(page: number) {
177
+ if (props.syncUrl) {
178
+ emit("pageChange", page);
179
+ updateUrl({ page: String(page) });
180
+ } else {
181
+ table.handlePageChange(page);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Handle page size change with optional URL sync
187
+ */
188
+ function handlePageSizeChange(size: number | string) {
189
+ if (props.syncUrl) {
190
+ updateUrl({ pageSize: String(size), page: "1" });
191
+ } else {
192
+ table.handlePageSizeChange(size);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Handle sort change with optional URL sync
198
+ */
199
+ function handleOrderBy(event: { value: string; orderDirection: "asc" | "desc" }) {
200
+ if (props.syncUrl) {
201
+ emit("sortChange", event.value, event.orderDirection);
202
+ updateUrl({ sortKey: event.value, sortOrder: event.orderDirection, page: "1" });
203
+ } else {
204
+ table.handleOrderBy(event);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Extract actual value from filter value (handles uiSelect objects with { id, name })
210
+ */
211
+ function extractFilterValue(value: any): string | undefined {
212
+ if (value === undefined || value === null || value === "") {
213
+ return undefined;
214
+ }
215
+ // If value is an object with 'id' property (uiSelect format), extract the id
216
+ if (typeof value === "object" && value !== null && "id" in value) {
217
+ return String(value.id);
218
+ }
219
+ return String(value);
220
+ }
221
+
222
+ /**
223
+ * Handle filters applied with optional URL sync
224
+ */
225
+ function handleFiltersApplied(filters: any) {
226
+ if (props.syncUrl) {
227
+ // Convert filter data to URL params
228
+ const filterParams: Record<string, string | undefined> = { page: "1" };
229
+
230
+ // Handle uiTable format: { logicOperator: 'and', filters: [...] }
231
+ if (filters && typeof filters === "object" && "filters" in filters && Array.isArray(filters.filters)) {
232
+ filters.filters.forEach((f: { name: string; value: any }) => {
233
+ const extractedValue = extractFilterValue(f.value);
234
+ if (extractedValue !== undefined) {
235
+ filterParams[f.name] = extractedValue;
236
+ }
237
+ });
238
+ }
239
+ // Handle SmartFilter array format: [{ category, operator, value }, ...]
240
+ else if (Array.isArray(filters)) {
241
+ filters.forEach((f: { category?: string; name?: string; value: any }) => {
242
+ const key = f.category || f.name;
243
+ const extractedValue = extractFilterValue(f.value);
244
+ if (key && extractedValue !== undefined) {
245
+ filterParams[key] = extractedValue;
246
+ }
247
+ });
248
+ }
249
+ // Handle simple object format: { email: 'john', status: 'active' }
250
+ else if (filters && typeof filters === "object") {
251
+ Object.entries(filters).forEach(([key, value]) => {
252
+ // Skip non-filter keys
253
+ if (key === "logicOperator" || key === "filters") return;
254
+ const extractedValue = extractFilterValue(value);
255
+ if (extractedValue !== undefined) {
256
+ filterParams[key] = extractedValue;
257
+ }
258
+ });
259
+ }
260
+
261
+ emit("filterChange", filterParams);
262
+ updateUrl(filterParams);
263
+ } else {
264
+ table.handleSmartFiltersApplied(filters);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Handle filters cleared with optional URL sync
270
+ */
271
+ function handleFiltersCleared() {
272
+ if (props.syncUrl) {
273
+ // Clear all filter params from URL
274
+ if (typeof window === "undefined") return;
275
+ const url = new URL(window.location.href);
276
+ // Keep only non-filter params
277
+ const keysToRemove: string[] = [];
278
+ url.searchParams.forEach((_, key) => {
279
+ if (!["page", "pageSize", "sortKey", "sortOrder"].includes(key)) {
280
+ keysToRemove.push(key);
281
+ }
282
+ });
283
+ keysToRemove.forEach((key) => url.searchParams.delete(key));
284
+ url.searchParams.set("page", "1");
285
+ window.location.href = url.toString();
286
+ } else {
287
+ table.handleSmartFiltersCleared();
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Handle single filter deleted with optional URL sync
293
+ */
294
+ function handleFilterDeleted(index: number) {
295
+ if (props.syncUrl) {
296
+ // For URL sync, we need to rebuild filters without the deleted one
297
+ // This is handled by the UI component, just trigger a page reload
298
+ handleFiltersCleared();
299
+ } else {
300
+ table.handleSmartFilterDeleted(index);
301
+ }
302
+ }
303
+
304
+ // Initialize useTable with reactive items
305
+ const table = useTable<T>({
306
+ items: toRef(() => props.items),
307
+ pageSize: props.pageSize,
308
+ sortKey: props.defaultSortKey ?? "",
309
+ sortOrder: props.defaultSortOrder,
310
+ idKey: props.idKey as string,
311
+ });
312
+
313
+ // Transform columns to table header format
314
+ const tableHeader = computed(() =>
315
+ props.columns.map((col) => ({
316
+ name: col.key,
317
+ label: col.label,
318
+ sortable: col.sortable !== false,
319
+ width: col.width,
320
+ }))
321
+ );
322
+
323
+ // Transform items to table row format
324
+ // When syncUrl is true, data is already paginated server-side, use props.items directly
325
+ const tableItems = computed(() => {
326
+ const itemsToRender = props.syncUrl ? props.items : table.paginatedItems.value;
327
+ return itemsToRender.map((item, itemIndex) => ({
328
+ id: item[props.idKey as keyof T],
329
+ row: props.columns.map((col) => {
330
+ // Use custom render if provided
331
+ if (col.render) {
332
+ return col.render(item, itemIndex);
333
+ }
334
+
335
+ // Get raw value
336
+ const value = item[col.key as keyof T];
337
+
338
+ // Apply type-based formatting
339
+ return formatCellValue(value, col.type);
340
+ }),
341
+ // Store original item for action handlers
342
+ _originalData: item,
343
+ }));
344
+ });
345
+
346
+ // Transform filters to smart filter categories
347
+ const filterCategories = computed(() =>
348
+ props.filters?.map((f) => ({
349
+ id: f.key,
350
+ name: f.label,
351
+ componentType: mapFilterType(f.type),
352
+ defaultProps: buildFilterProps(f),
353
+ })) ?? []
354
+ );
355
+
356
+ // Convert initial filters (from URL params) to Table's filter format
357
+ const initialTableFilters = computed(() => {
358
+ if (!props.initialFilters || !props.filters) return undefined;
359
+
360
+ const filterEntries = Object.entries(props.initialFilters).filter(
361
+ ([, value]) => value !== undefined && value !== null && value !== ""
362
+ );
363
+
364
+ if (filterEntries.length === 0) return undefined;
365
+
366
+ return {
367
+ logicOperator: "and",
368
+ filters: filterEntries.map(([key, value]) => {
369
+ const filterDef = props.filters?.find((f) => f.key === key);
370
+ return {
371
+ name: key,
372
+ label: filterDef?.label ?? key,
373
+ type: filterDef ? mapFilterType(filterDef.type) : "uiInput",
374
+ value: String(value),
375
+ };
376
+ }),
377
+ };
378
+ });
379
+
380
+ // Build row actions for table
381
+ const rowActions = computed(() => {
382
+ if (!props.actions || props.actions.length === 0) return undefined;
383
+
384
+ return props.actions.map((action) => {
385
+ if (typeof action === "string") {
386
+ return { name: action, id: action };
387
+ }
388
+ return { name: action.label, id: action.id };
389
+ });
390
+ });
391
+
392
+ // Build bulk action buttons
393
+ const bulkActionButtons = computed(() => {
394
+ if (!props.bulkActions || !props.selectable) return undefined;
395
+
396
+ return props.bulkActions.map((action) => ({
397
+ text: action.label,
398
+ id: action.id,
399
+ color: action.variant === "danger" ? "red" : action.variant === "warning" ? "yellow" : "blue",
400
+ icon: action.icon,
401
+ }));
402
+ });
403
+
404
+ // Select all modal config
405
+ const selectAllModal = computed(() => {
406
+ if (!props.selectable) return undefined;
407
+ return {
408
+ title: "Select All Items",
409
+ message: `Select all ${table.totalItems.value} items?`,
410
+ cancelText: "Select page only",
411
+ confirmText: "Select all",
412
+ };
413
+ });
414
+
415
+ /**
416
+ * Format cell value based on type
417
+ */
418
+ function formatCellValue(value: any, type?: string): any {
419
+ if (value === null || value === undefined) return "";
420
+
421
+ switch (type) {
422
+ case "date":
423
+ if (value instanceof Date) {
424
+ return value.toLocaleDateString();
425
+ }
426
+ if (typeof value === "string") {
427
+ return new Date(value).toLocaleDateString();
428
+ }
429
+ return value;
430
+
431
+ case "number":
432
+ return typeof value === "number" ? value.toLocaleString() : value;
433
+
434
+ case "badge":
435
+ return { content: value, type: "badge" };
436
+
437
+ case "tag":
438
+ return { content: value, type: "tag" };
439
+
440
+ default:
441
+ return value;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Map filter type to component type
447
+ */
448
+ function mapFilterType(type: string): "uiInput" | "uiSelect" {
449
+ switch (type) {
450
+ case "select":
451
+ return "uiSelect";
452
+ default:
453
+ return "uiInput";
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Build filter component props
459
+ */
460
+ function buildFilterProps(filter: DataTableFilter): Record<string, any> {
461
+ const defaultProps: Record<string, any> = {};
462
+
463
+ if (filter.placeholder) {
464
+ defaultProps.placeholder = filter.placeholder;
465
+ }
466
+
467
+ if (filter.type === "select" && filter.options) {
468
+ // uiSelect smart filter uses 'name' as the filter value and 'label' for display
469
+ // Format: { id: unique, name: actualValue, label: displayText }
470
+ defaultProps.items = filter.options.map((opt, index) => ({
471
+ id: String(index),
472
+ name: String(opt.value), // This is what gets used as filter value
473
+ label: opt.label, // This is what gets displayed
474
+ }));
475
+ }
476
+
477
+ if (filter.type === "number") {
478
+ defaultProps.type = "number";
479
+ }
480
+
481
+ return defaultProps;
482
+ }
483
+
484
+ /**
485
+ * Handle table action (row action)
486
+ */
487
+ function handleTableAction(data: { action: string; items: Array<string | number> }) {
488
+ // Find the item that was acted upon
489
+ const itemId = data.items[0];
490
+ const tableItem = tableItems.value.find((ti) => ti.id === itemId);
491
+ if (tableItem && tableItem._originalData) {
492
+ emit("action", data.action, tableItem._originalData);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Handle bulk action button click
498
+ */
499
+ function handleBulkActionClick(data: { id: string; text: string }) {
500
+ emit("bulkAction", data.id, table.selectedItems.value);
501
+ }
502
+
503
+ /**
504
+ * Handle custom cell emit (e.g., link clicks with emits config)
505
+ */
506
+ function handleCustomEmit(data: any) {
507
+ emit("customEmit", data);
508
+ }
509
+
510
+ /**
511
+ * Watch for selection changes
512
+ */
513
+ watch(
514
+ () => table.selectedItems.value,
515
+ (items) => {
516
+ emit("selection", items);
517
+ },
518
+ { deep: true }
519
+ );
520
+ </script>
521
+
522
+ <template>
523
+ <Table
524
+ :header="tableHeader"
525
+ :items="tableItems"
526
+ :loading="loading"
527
+ :current-page="syncUrl ? currentPage : table.currentPage.value"
528
+ :total-pages="syncUrl ? totalPages : table.totalPages.value"
529
+ :total-items="syncUrl ? totalItems : table.totalItems.value"
530
+ :page-size="syncUrl ? pageSize : table.pageSize.value"
531
+ :ordered-by="defaultSortKey ?? table.sortKey.value"
532
+ :order-direction="defaultSortOrder ?? table.sortOrder.value"
533
+ :smart-filter-categories="filterCategories"
534
+ :filters="initialTableFilters"
535
+ :hidden-columns="table.hiddenColumns.value"
536
+ :reset-selected="table.resetSelected.value"
537
+ :actions="rowActions"
538
+ :select-all-items-modal="selectAllModal"
539
+ :table-action-buttons="bulkActionButtons"
540
+ :no-results="noResults"
541
+ @change-page="handlePageChange"
542
+ @change-page-size="handlePageSizeChange"
543
+ @order-by="handleOrderBy"
544
+ @smart-filters-sent="handleFiltersApplied"
545
+ @smart-filters-cleared="handleFiltersCleared"
546
+ @smart-filter-deleted="handleFilterDeleted"
547
+ @columns-visibility-changed="table.handleColumnsVisibilityChanged"
548
+ @modal-action="table.handleModalAction"
549
+ @table-action="handleTableAction"
550
+ @table-action-button-clicked="handleBulkActionClick"
551
+ @custom-emit="handleCustomEmit"
552
+ />
553
+ </template>